Wikilivres
frwikibooks
https://fr.wikibooks.org/wiki/Accueil
MediaWiki 1.47.0-wmf.23
first-letter
Média
Spécial
Discussion
Utilisateur
Discussion utilisateur
Wikilivres
Discussion Wikilivres
Fichier
Discussion fichier
MediaWiki
Discussion MediaWiki
Modèle
Discussion modèle
Aide
Discussion aide
Catégorie
Discussion catégorie
Transwiki
Discussion Transwiki
Wikijunior
Discussion Wikijunior
TimedText
TimedText talk
Module
Discussion module
Event
Event talk
Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions
0
65843
774586
774585
2026-10-10T12:01:25Z
Mewtow
31375
/* Les dépendances de données */
774586
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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é d'accès 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 qui va suivre, nous allons nous limiter aux dépendances liées aux registres, et mettre de côté les dépendances d'adresse. Intuitivement, on se dit que de les dépendances de registres concernent les instructions arithmétiques, mais les instructions d'accès mémoire peuvent avoir des dépendances de registre avec d'autres instructions. L'instruction STORE lit la donnée à écrire et son adresse dans les registres, elle a donc potentiellement des dépendances de registre, à cause des registre opérande. Si une autre instruction lit/écrit ces registres opérandes, une dépendance de registre survient. L'instruction LOAD est dans un cas similaire, : elle récupère l'adresse à lire dans un registre opérande (la plupart du temps), et écrit la donnée lue dans un autre registre de destination. 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.
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é.
===Les dépendances présentes dépendent du pipeline===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines multicycles
| prevText=Les pipelines multicycles
| next=Le contournement (data forwarding)
| nextText=Le contournement (data forwarding)
}}
</noinclude>
73ug0x3ec3hm1bm8peywd6aoof9d22j
774587
774586
2026-10-10T12:01:53Z
Mewtow
31375
/* Les dépendances de registre et mémoire */
774587
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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 qui va suivre, nous allons nous limiter aux dépendances liées aux registres, et mettre de côté les dépendances d'adresse. Intuitivement, on se dit que de les dépendances de registres concernent les instructions arithmétiques, mais les instructions d'accès mémoire peuvent avoir des dépendances de registre avec d'autres instructions. L'instruction STORE lit la donnée à écrire et son adresse dans les registres, elle a donc potentiellement des dépendances de registre, à cause des registre opérande. Si une autre instruction lit/écrit ces registres opérandes, une dépendance de registre survient. L'instruction LOAD est dans un cas similaire, : elle récupère l'adresse à lire dans un registre opérande (la plupart du temps), et écrit la donnée lue dans un autre registre de destination. 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.
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é.
===Les dépendances présentes dépendent du pipeline===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines multicycles
| prevText=Les pipelines multicycles
| next=Le contournement (data forwarding)
| nextText=Le contournement (data forwarding)
}}
</noinclude>
99xs4fnfmjhnci96ox84yw2wv00aehm
774588
774587
2026-10-10T12:04:58Z
Mewtow
31375
/* Les dépendances de registre */
774588
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines multicycles
| prevText=Les pipelines multicycles
| next=Le contournement (data forwarding)
| nextText=Le contournement (data forwarding)
}}
</noinclude>
mq78bv4kg1mv8ywpxtlh32awtr8vumi
774599
774588
2026-10-10T12:25:20Z
Mewtow
31375
/* L'exécution dans le désordre et autres alternatives */
774599
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le contournement (data forwarding)
| nextText=Le contournement (data forwarding)
}}
</noinclude>
hi75ttr3pep2hs7bglczyx6i8mlne7e
774621
774599
2026-10-10T12:54:24Z
Mewtow
31375
/* L'exécution dans le désordre et autres alternatives */
774621
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le contournement (data forwarding)
| nextText=Le contournement (data forwarding)
}}
</noinclude>
bf27d5ad6wiopcrk5wzys74l5j1h8kl
774623
774621
2026-10-10T12:55:00Z
Mewtow
31375
/* Le replay pipeline du Pentium 4 */
774623
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Elle est parfois fusionnée avec l'unité de décodage d'instruction, mais nous allons surtout étudier le cas où elle est séparée. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
1d77ry38yaqvjrmwfffzbq8nzfeytks
774637
774623
2026-10-10T13:39:48Z
Mewtow
31375
/* L'émission d'une instruction */
774637
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Le processeur doit détecter les dépendances de données entre instructions. Les dépendances de registres se détectent en comparant entre eux les registres utilisés par les instructions. Les registres étant connus dès la sortie de l'étape de décodage, la détection des dépendances de données se fait de manière précoce, avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
La détection des dépendances de registre se fait dans l'étage d'émission, situé juste après l'étage de décodage d'instruction. L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances avec les instructions dans le pipeline. À chaque cycle d'horloge, l'unité d'émission analyse une instruction décodée appelée '''instruction candidate'''. Elle détecte si cette instruction candidate a une dépendance avec une instruction déjà dans le pipeline. Les instructions dans le pipeline n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''instructions "en vol"'''.
Pour cela, l'unité d'émission compare les registres utilisés par l'instruction candidate et les instructions dans le pipeline. 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les instructions en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
lzqa58umk1ovy1w04j0h5kn3xilh37y
774638
774637
2026-10-10T13:43:57Z
Mewtow
31375
/* La détection des dépendances de données */
774638
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
Précision importante : les dépendances ne sont pertinentes que pour les instructions 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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | Instructions en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des instructions en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! Instructions en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit connaitre les registres de destination des instructions 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
dzpk3t61hfcb68s15wjekppbjvu8lji
774639
774638
2026-10-10T13:44:22Z
Mewtow
31375
774639
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===L'exécution dans l'ordre et dans le désordre des instructions===
Nous venons de voir que les dépendances de données posent des problèmes qu'un pipeline de longueur variable doit résoudre. La solution la plus simple retarde les instructions candidates problématiques de quelques cycles d'horloges. Si une instruction a une dépendance problématique avec une autre, il suffit simplement de l’exécuter avec un retard de cycles. Pour cela, lorsqu'une instruction multicycle est démarrée, les instructions problématiques sont bloquées à l'étage de décodage en attendant le bon moment. Pour cela, on retarde l'instruction fautive dans l'unité de décodage.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Bulle de pipeline.]]
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Pour éviter de bloquer le pipeline, les processeurs modernes incorporent des optimisations assez intéressantes. Nous verrons la technique du contournement dans le chapitre suivant. Elle permet de ne pas bloquer le pipeline en cas de dépendance ''Read after write''. Une solution complémentaire est ce qu'on appelle l''''exécution dans le désordre''', une optimisation commune sur les processeurs modernes qui sera vue en détail dans plusieurs chapitres dans la suite du cours.
L'idée est que si une instruction problématique est bloquée dans l'unité de décodage/émission, elle est juste mise en attente, elle ne bloque pas le pipeline. Le processeur regarde les instructions suivantes et vérifie si certaines d'entre elles peuvent s'exécuter. Les instructions mises en attente sont exécutées dès que possible, elles ont la priorité. La gestion de la mise en attente des instructions problématiques est assez complexe et implique de les stocker dans des mémoires spécifiques. Là encore, il faut que les instructions exécutées en parallèle ne manipulent pas les mêmes données, ni les mêmes registres, ni les mêmes adresses mémoires.
La toute première implémentation de l'exécution dans le désordre était sur l'ordinateur CDC 6600, suivi par son successeur le CDC 7600. Il intégrait un circuit appelé le '''''scoreboard''''', qui gérait l'exécution dans le désordre. Décrire le circuit en question est assez compliqué, aussi je le laisse de côté pour le moment. Sachez le terme ''scoreboard'' est resté et a été élargit à tout circuit d'émission, qu'il soit dans le désordre ou dans l'ordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
f6l1ogcjc47hftdyp0v81hm35w8vo45
774640
774639
2026-10-10T13:46:06Z
Mewtow
31375
/* L'exécution dans l'ordre et dans le désordre des instructions */
774640
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
==L'étage d'émission : le blocage du pipeline==
Maintenant, voyons comment est implémenté l'étage d'émission (''issue''), qui détecte les situations problématiques et bloque le pipeline si besoin. Il suit immédiatement l'étage de décodage, et est situé avant le chemin de données. Le rôle de l'unité d'émission est de générer de bloquer le pipeline. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. On dit qu'elle émet l'instruction.
L''''émission''' est donc l'envoi d'une micro-opération dans le pipeline, si les conditions sont réunies. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
thbhjdbawx3apc7ez9i5ocd841rw2xm
774641
774640
2026-10-10T13:46:52Z
Mewtow
31375
/* L'étage d'émission : le blocage du pipeline */
774641
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
lflmuq5l3g01dku4jmoukrcpvjbvopp
774642
774641
2026-10-10T13:47:43Z
Mewtow
31375
/* L'émission d'une instruction */
774642
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'envoie dans la suite du pipeline. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
f04vbn9a38w7a8nwjl4xqosro2cgwvg
774643
774642
2026-10-10T13:48:33Z
Mewtow
31375
/* L'émission d'une instruction */
774643
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La gestion des dépendances de données===
Pour rappel, l'unité d'émission détecte les dépendances entre une '''instruction candidate''' et les instructions déjà dans le pipeline, aussi appelées '''instructions "en vol"'''. L'unité d'émission détecte les dépendances entre l'instruction candidate et chaque instruction en vol. Pour rappel, les dépendances de données se détectent en comparant les instructions de deux opérandes. Vu que les dépendances WAR sont très rares, et n'existent pas sur les pipelines normaux, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
dj2v6z5dynhxzw8ehhg0axf9kirnqg5
774644
774643
2026-10-10T13:50:19Z
Mewtow
31375
/* La gestion des dépendances de données */
774644
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre). Cependant, on peut grandement simplifier l'algorithme et ne pas tenir compte des dépendances WAR, qui n'existent pas sur la plupart des pipelines dynamiques sans exécution dans le désordre. Le tableau se simplifie en retirant les dépendances inutiles, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol.
{|class="wikitable"
|-
! rowspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
Registres pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
oq56ru6ky3d5b3gcvxpxs0iefpx6klb
774645
774644
2026-10-10T13:51:38Z
Mewtow
31375
/* La détection des dépendances de données */
774645
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
7fddohlrazleuoud5aowmqj01ggj94t
774646
774645
2026-10-10T13:52:06Z
Mewtow
31375
/* L'émission d'une instruction */
774646
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Vous vous dites sans doute que les accès mémoire sont des instructions multicycles, et ce n'est pas faux. Mais elles se distinguent des autres instructions multicycle par leur temps d'exécution variable. Un accès au cache peut prendre deux à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, les techniques utilisées sur les pipelines multicycle/dynamiques ne fonctionnent pas parfaitement. La gestion des dépendances de données est notamment totalement différente.
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux instructions de calcul, les accès mémoire ont pour défaut qu'ils ont un nombre de cycles variable. Sur la plupart des processeurs, l'accès au cache se fait rapidement, mais l'accès à la mémoire est très lent. Et l'unité d'émission doit gérer la disponibilité de l'unité mémoire.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
argdk21d49vwytkn3s7ve2tiwlz63xn
774647
774646
2026-10-10T14:03:01Z
Mewtow
31375
/* L'émission et les micro-opérations mémoire */
774647
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Nous utiliserons le terme unité mémoire dans la suite du cours. L'unité mémoire est pour le moment une sorte de boite noire, dont on ne détaillera pas l'intérieur, et dont seules les entrées et sorties nous sont accessibles. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée des opérandes, à savoir les adresses à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue et le registre de destination dans lequel enregistrer la donnée. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. L'adressage base + indice est un bon exemple : il demande de calculer l'adresse à lire/écrire avec une addition et éventuellement un décalage. Dans le chapitre sur le pipeline, nous avons vu que les calculs d'adresse peuvent être effectués soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ne sont pas équivalentes, elles ont chacune leurs avantages et leurs défauts. Un des avantages est lié au décodage des instructions, à savoir si les instructions mémoire sont décodées en une ou deux micro-opérations.
La solution la plus simple est celle qui effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. Si l'unité mémoire effectue les calculs d'adresse, les instructions mémoire sont décodées en une seule micro-opération, qui est envoyée à l'unité mémoire. Pas besoin de micro-opération séparée pour le calcul d'adresse. Le défaut de cette méthode est que l'unité mémoire doit incorporer une unité de calcul d'adresse, ce qui ajoute quelques circuits redondants avec l'ALU entière. Mais les circuits ajoutés se résument à des additionneurs et un circuit de décalage, ce qui est très léger. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire. Le fait que le décodage soit simplifié l'emporte haut la main, comparé aux économies liées à l'unité de calcul d'adresse.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux autres instructions multicycles, les accès mémoire ont un nombre de cycles variable. Un accès au cache peut prendre un à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, la gestion des dépendances est notamment totalement différente.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
c4bm0gu92v3elimkiv7him2jyghyhf0
774648
774647
2026-10-10T14:12:22Z
Mewtow
31375
/* L'émission et les micro-opérations mémoire */
774648
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres.
En général, l'adresse est lue dans les registres, mais elle peut aussi être fournie par le réseau de contournement. Mais certains modes d'adressage font que l'adresse doit être calculée. Le calcul d'adresse peut être fait soit dans les unités de calcul entières, soit dans l'unité mémoire elle-même. Et les deux solutions ont chacune leurs avantages et leurs défauts. La solution la plus simple effectue les calculs d'adresse dans l'unité mémoire elle-même. L'interface de l'unité mémoire est modifiée, de manière à pouvoir lui envoyer les indices et décalages nécessaires au calcul d'adresse. De nos jours, tous les processeurs font les calculs d'adresse dans l'unité mémoire.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux autres instructions multicycles, les accès mémoire ont un nombre de cycles variable. Un accès au cache peut prendre un à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, la gestion des dépendances est notamment totalement différente.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
elgt9s41bzo6c9ll7p3dgzgzupkfiuy
774649
774648
2026-10-10T14:14:36Z
Mewtow
31375
/* L'émission et les micro-opérations mémoire */
774649
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux autres instructions multicycles, les accès mémoire ont un nombre de cycles variable. Un accès au cache peut prendre un à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, la gestion des dépendances est notamment totalement différente.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
3q3wwau6j1ybht8m3pw9nc0tx3uz7yd
774653
774649
2026-10-10T15:51:30Z
Mewtow
31375
/* L'émission et les micro-opérations mémoire */
774653
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux autres instructions multicycles, les accès mémoire ont un nombre de cycles variable. Un accès au cache peut prendre un à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, la gestion des dépendances est notamment totalement différente.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===Les lectures non-bloquantes===
Les '''lectures non-bloquante''' sont une optimisation qui évite de bloquer le pipeline en cas d'accès mémoire. Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès. L'idée est de continuer à faire des calculs en parallèle de l'accès mémoire, sous condition que cela ne pose pas de problèmes. Les instructions exécutées en parallèle sont exécutées dans l'unité de calcul, nous allons donc les appeler des opérations. L'unité d'émission ne peut pas émettre de nouvel accès mémoire, mais peut exécuter des opérations en parallèle de la lecture, si elles sont indépendantes de la lecture. Reste à savoir sous quelles conditions les opérations sont indépendants de la lecture.
La technique des lectures non-bloquantes ne se préoccupe pas des dépendances d'adresses. En effet, elles sont gérées par l'unité mémoire, pas par l'unité d'émission. Elle ne se préoccupe que des dépendances de registre. Les lectures et écritures lisent des opérandes dans les registres et ne sont pas émises tant qu'il y a une dépendance avec ces registres. Mais ce qui nous intéresse est ce qui se passe une fois la lecture/écriture émise. Une fois émises, les écritures n'écrivent dans aucun registre et n'ont donc pas de dépendances de registre. Par contre, les lectures écrivent la donnée lue dans un registre de destination, ce qui peut faire apparaitre des dépendances.
Le premier problème est qu'il se peut qu'une opération utilise la donnée lue par l'accès mémoire. Par exemple, la lecture charge une donnée dans un registre et ce registre est ensuite utilisé comme opérande d'une addition. Dans ce cas, l'opération ne doit pas s’exécuter tant que la donnée n'a pas été chargée. Un autre cas est celui où une opération écrit dans le registre de destination de la lecture. Avec des lectures non-bloquantes, l'opération ne doit pas démarrer tant que la lecture n'est pas terminée. Les dépendances pertinentes ici sont des dépendances avec le registre de destination de la lecture.
L'unité d'émission vérifie les dépendances avec le registre de destination de la lecture. Pour cela, l'unité d'émission marque le registre de destination de la lecture comme « réservé », du moins tant que la lecture n'a pas écrit son résultat dedans. Lorsqu'elle émet une lecture, l'unité d'émission marque le registre de destination comme réservé. Elle continue d’émettre des instructions si elles sont indépendantes de ce registre destination, mais bloque l'émission de toute instruction qui l'utilise. Ainsi, les instructions qui ne lisent ou n'écrivent pas ce registre peuvent s'exécuter dans les unités de calcul.
L'implémentation de cette technique demande juste de pouvoir réserver des registres, et consulter les réservations à chaque cycle. Pour marquer les registres comme réservés/non-réservé, l'unité d'émission contient un ''bit de réservation en lecture'' par registre. Chaque bit indique si le registre associé contient une donnée valide ou non. Le bit de validité est automatiquement comme invalide quand le processeur démarre une lecture. Le tout est regroupé dans un '''registre de réservation'''. Le registre de réservation peut être fusionné avec le registre de disponibilité vu plus haut.
Toute instruction qui a un registre invalide comme opérande est mise en attente. Idem pour les instructions qui ont un registre invalide comme registre de destination. À chaque cycle, l'unité d'émission tente de démarrer une nouvelle instruction et vérifie si elle est dépendante de la lecture. Elle vérifie si l'instruction à émettre a le registre de destination de la lecture comme opérande, ou comme registre de destination. Si c'est le cas, alors l'instruction est bloquée dans le pipeline. Mais dans le cas contraire, l'instruction de calcul est indépendante de l'accès mémoire, et peuvent démarrer sans trop de problèmes.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
mno40jvklmk3ivkcnn6pylrlmqukrmy
774657
774653
2026-10-10T16:32:44Z
Mewtow
31375
/* Les lectures non-bloquantes */ Retrait d'un paragraphe totalement faux : ce sont les défauts de cache qui sont bloquants, pas les lectures, les lectures non-bloquantes proprement dites sont gérées naturellement par l'unité d'émission.
774657
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission et les micro-opérations mémoire==
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Gérer les accès mémoire demande, entre autres, de gérer la disponibilité de l'unité mémoire. Mais contrairement aux autres instructions multicycles, les accès mémoire ont un nombre de cycles variable. Un accès au cache peut prendre un à trois cycles d’horloge, un accès au cache L2 prend facilement 10 cycles, et un accès en RAM une centaine. Vu que leur latence n'est pas connue à l'avance, la gestion des dépendances est notamment totalement différente.
===Les unités mémoires bloquantes===
Dans ce qui suit, nous partons du principe que l'unité mémoire ne peut faire qu'un seul accès mémoire à la fois. Hypothèse crédible, mais certains processeurs avec exécution dans le désordre fonctionnent autrement. L'unité mémoire prévient elle-même de sa disponibilité. Elle est reliée à l'unité d'émission via un fil MEM_UNIT_READY qui prévient qu'elle est disponible et peut accepter un nouvel accès mémoire. Si le bit MEM_UNIT_READY est à 1, l'unité mémoire est inutilisé, il n'y a pas d'accès mémoire en cours. Par contre, si ce bit est à 0, alors un accès mémoire est en cours et l'unité mémoire ne peut pas accepter de nouvel accès mémoire.
L'unité d'émission tient en compte de ce bit pour décider de l'émission des instructions en cours. De plus, elle l'utilise pour déterminer si le registre de destination est disponible. Quand le signal MEM_UNIT_READY passe de 0 à 1, le no/numéro du registre destination est disponible en sortie de l'unité mémoire. Il est alors envoyé à l'unité d'émission et le registre de disponibilité est mis à jour.
[[File:Implémentation du stall lors d'un accès mémoire dans l'unité d'émission.png|centre|vignette|upright=2|Implémentation du stall lors d'un accès mémoire dans l'unité d'émission]]
Pour éviter tout problème, l'idéal est de stopper l'exécution d'autres instructions tant qu'un accès mémoire a lieu. Les instructions qui suivent l'accès mémoire sont mises en attente tant qu'il n'est pas terminé. En clair, on bloque le pipeline, l'unité d'émission cesse d'émettre des instructions et bloque le pipeline. Pour cela, dès que l'unité d'émission émet une micro-opération mémoire, elle bloque le pipeline tant que le signal MEM_UNIT_READY est à 0. Une fois l'accès mémoire terminé, l'unité d'accès mémoire met le signal MEM_UNIT_READY à 1, ce qui prévient l'unité d'émission. L'émission des instructions reprend. C'est la solution la plus simple, mais elle est imparfaite.
===L'exécution dans le désordre et autres alternatives===
Les lectures non-bloquantes peuvent être vues comme une sorte d'exécution dans le désordre limitée aux lectures. La différence avec l'exécution dans le désordre est que la technique des lectures non-bloquantes détecte si une instruction est dépendante d'une lecture, alors que l’exécution dans le désordre détectent toutes les dépendances entre instructions, pas seulement les dépendances avec un accès mémoire.
Le processeur ROCK, annulé en 2010, utilisait une version améliorée des lectures non-bloquantes, appelée la technique des '''éclaireurs matériels'''. La différence est que le processeur ne stoppe pas quand il rencontre une instruction dépendante de la lecture. À la place, les instructions dépendantes de la lecture sont mises en attente dans une file d'attente spécialisée, appelée la '''file d’attente différée''' (''deferred queue''), qui est une mémoire FIFO un peu modifiée. Une fois que la lecture renvoie un résultat, le processeur cesse l’exécution des instructions et exécute les instructions mises en attente. Les instructions mises en attente sont exécutées dans l'ordre, avant de reprendre là où le programme s'était arrêté.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
4zu7fagtzmwqnkdter24tw9v33mr7z7
774658
774657
2026-10-10T16:33:00Z
Mewtow
31375
/* L'émission et les micro-opérations mémoire */
774658
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
5kjzrszv0xbdkenzgpu4fymdrlalc8w
774713
774658
2026-10-10T19:05:07Z
Mewtow
31375
/* La gestion de la disponibilité des ALUs */
774713
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
6uq086ovh1cr69wsvs6oomc20oax2um
774718
774713
2026-10-10T19:13:31Z
Mewtow
31375
/* Les dépendances présentes dépendent du pipeline */
774718
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'émission d'une instruction==
Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances de registre dans un circuit spécialisé, appelé l''''unité d'émission'''. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée.
[[File:Pipeline bubble.png|centre|vignette|upright=2|Pipeline bubble]]
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 et se propage dans le reste du pipeline. 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 rôle de l'unité d'émission est de bloquer le pipeline si besoin. Si elle reçoit une instruction problématique, elle la bloque dans l'unité d'émission et bloque le pipeline tant que la dépendance n'est pas réglée. Mais si l'instruction peut s'exécuter, elle l'émet. Un point important de l'émission est que le processeur émet une instruction par cycle, en absence de dépendance de données, mais aussi en absence de dépendances structurelles, les deux étant détectées par l'unité d'émission.
Notons que cette technique exécute des instructions indépendantes dans des unités de calcul séparées. Dans l'exemple précédent avec une instruction multicycle suivie par une instruction normale, le processeur détecte les situations où ce n'est pas grave si la seconde instruction finisse avant la première : on peut alors lancer la seconde instruction immédiatement sans attendre. Il y a donc un gain en performance. Un point important est que cette technique émet les instructions dans l'ordre, mais qu'elles peuvent s'exécuter dans le désordre et terminer dans le désordre. En clair, les instructions sont injectées dans les unités de calcul dans l'ordre, mais en sortent dans le désordre.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, les dépendances de registres se détectent avant même l'exécution des instructions. Une sacré différence avec les dépendances d'adresse, qui demandent que les adresses soient calculées et lues dans les registres avant qu'on puisse détecter les dépendances.
L'unité d'émission reçoit une instruction décodée et vérifie ses dépendances. La micro-opération en question est 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. Et précisément, celles qui sont dans les étages suivant l'unité d'émission. Les micro-opérations en question n'ont pas encore enregistrées leurs résultats dans les registres, soit parce qu'elles sont en cours d'exécution, qu'elles lisent leurs opérandes, peut importe. Elles seront appelées '''micro-opérations "en vol"'''.
Pour cela, 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. Les quatre situations possibles sont les suivantes :
* Un des registres opérande est à lire (opérande) par une instruction dans le pipeline : pas de dépendance.
* Un des registres opérande est écrit (destination) par une instruction dans le pipeline : dépendance RAW.
* Un des registres de destination est à lire (opérande) par une instruction dans le pipeline : potentielle dépendance WAR, mais seulement sur des pipelines pathologiques jamais utilisés.
* Un des registres de destination est écrit (destination) par une instruction dans le pipeline : dépendance WAW.
Notez que pour les lectures, on part du principe que les micro-opérations en vol n'ont pas encore lu leurs registres opérandes. Dès qu'elles ont lu les opérandes en question, les dépendances liées disparaissent. Pareil pour les écritures, sauf que celles-ci ont lieu à la toute fin du pipeline. Les écritures en attente ont donc lieu quand l'instruction termine.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! rowspan="2" | micro-opérations en vol
! Registres pas encore lus (opérandes)
| Pas de dépendances (dépendance RAR inoffensive)
| Dépendance WAR potentielle, pas en pratique
|-
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
L'algorithme est donc le suivant : une lecture est autorisée en absence d'écriture (dans le même registre), une écriture est autorisée en absence de lecture (dans le même registre).
Les dépendances WAR n'existent pas sur les pipelines dynamiques de ce chapitre, elles demandent que le processeur utilise l'exécution dans le désordre. Ce qui fait que dans la suite, on ne tient compte que des dépendances RAW et WAW. Les détecter demande de comparer les registres de l'instruction candidate, avec les registres de destination des micro-opérations en vol. Le tableau précédent se simplifie alors, seule la dernière ligne nous intéresse au final.
{|class="wikitable"
|-
! rowspan="2" colspan="2" |
! colspan="2" | Instruction candidate
|-
! Registres opérandes !! Registre destination
|-
! micro-opérations en vol
! Registre pas encore écrits (destination)
| Dépendance RAW
| Dépendance WAW
|}
===Le registre de disponibilité et de réservation===
L'unité d'émission doit 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 écrit par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, l'instruction n'est pas émise.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Vous aurez peut-être remarqué, mais une différence est que les pipelines fixes retardent les écritures dans le banc de registre avec des registres d'échelonnage, les pipelines dynamique retardent l'émission des instructions. Les deux méthodes ont sensiblement le même résultat.
Le '''registre de consultation''' permet de désigner les registres qui vont être lus par les micro-opérations en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés. Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances ''Read After Read''. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
o7tj8whvvzomf2uq298uiijt4i25z03
774757
774718
2026-10-11T00:45:37Z
Mewtow
31375
/* L'émission d'une instruction */ Retrait d'une section partiellement fausse. Les dépendances WAW et RAW devraient être traitées dans le chapitre sur les pipelines dynamiques ou celui sur l'OOO.
774757
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir les processeurs à émission dans l'ordre et allons parler longuement des '''dépendances de données'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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 données==
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. 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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
idxolwukqpsthlcnszt48a5n1hh672u
Fonctionnement d'un ordinateur/Le renommage de registres
0
65844
774792
774209
2026-10-11T01:47:41Z
Mewtow
31375
/* Les optimisations de calculs triviaux */
774792
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 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>
1km9xd8npbt2fapyx79lu1sdyhc5s30
Fonctionnement d'un ordinateur/L'exécution dans le désordre
0
65869
774659
774295
2026-10-10T16:43:31Z
Mewtow
31375
/* La microarchitecture d'un CPU à exécution dans le désordre */
774659
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
===L'alternative à l'exécution dans le désordre : les lectures non-bloquantes===
L'exécution dans le désordre a pour principal avantage de pouvoir exécuter des opérations arithmétiques pendant qu'un accès mémoire est en cours. Et même plusieurs µops arithmétiques apr cycle, sur un processeur superscalaire. Ce ne sont pas les seuls, mais ce sont les principaux. Les CPU ''in-order'' d'ARM incorporent de quoi profiter de ces avantages, sans exécution dans le désordre. Ils supportent les '''lectures non-bloquantes''', à savoir que le processeur continue à exécuter des µops entières/flottantes lors d'un accès mémoire.
Par contre, la technique est assez limitée. Si une instruction utilise la donnée lue, alors que la lecture n'est pas terminée, le pipeline est complètement gelé. Il y a alors une dépendance RAW avec la lecture, ce qui fait que des bulles de pipeline sont émises tant que la lecture n'est pas terminée. De même, le processeur se gèle s'il doit émettre une autre µops mémoire pendant qu'une lecture/écriture est en cours. Ils ne peut donc pas exécuter deux µops mémoire simultanées, iln'y a pas de parallélisme mémoire.
Le A53 a aussi d'autres limitations. Il ne peut exécuter que 8 instructions après la lecture. Après cette limite, il se bloque. De plus, parmi ces 8 instructions en vol, il ne peut y avoir que 4 instructions flottantes et une seule instruction SIMD, pas plus de 3 branchements. Sans doute que le processeur mémorise les instructions émises après la lecture dans une sorte de mini-ROB, pour les annuler au cas où la lecture déclenche une exception (comme un défaut de page ou autre).
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
m6qu3w9ey8ksugqpet20htff4cppdz3
774706
774659
2026-10-10T18:49:04Z
Mewtow
31375
/* L'alternative à l'exécution dans le désordre : les lectures non-bloquantes */
774706
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
lskex1moeklkll2ayluptsyp3igsn3o
774763
774706
2026-10-11T01:21:55Z
Mewtow
31375
/* Les accès mémoire avec l'exécution dans le désordre */
774763
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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'''. Pour rappel, deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) au même registre ou à la même adresse mémoire. 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. Voyons lesquels et comment ceux-ci sont gérés par le pipeline.
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.
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.
===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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
ofnpwutznpead42rksrje0gtvyrc6x8
774767
774763
2026-10-11T01:24:29Z
Mewtow
31375
/* Les dépendances de données */
774767
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
7sj9ubnuiee1zanaryz8uzcun25mbiz
774768
774767
2026-10-11T01:24:48Z
Mewtow
31375
774768
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
hi90f2emj2k2xi58wracxa7bcnm1djq
774771
774768
2026-10-11T01:27:20Z
Mewtow
31375
/* Les scheduler avec allocation temporelle statique */
774771
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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===
Le support des exceptions précises supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
bsaoqkfpyhbkb34h2qky6s0kzgtxv7z
774779
774771
2026-10-11T01:34:09Z
Mewtow
31375
/* Les dépendances présentes dépendent du pipeline */
774779
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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===
Le support des exceptions précises avec un ROB supprime pas mal de dépendances de données. Le fait que les écritures mémoire garantit que les écritures se fassent dans l'ordre du programme, il n'y a pas de situations où une écriture soit exécutée avant une écriture antérieure. Et cela suffit à faire disparaitre les dépendances WAW. 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.
Même sans exceptions précises, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
tmp4ueiydcnrrsb9qc2121fokm0s0f8
774780
774779
2026-10-11T01:37:37Z
Mewtow
31375
/* Les dépendances présentes dépendent du pipeline */
774780
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
ex1nmfnjlf6mqvs62hc1vd9m52darhg
774781
774780
2026-10-11T01:38:00Z
Mewtow
31375
/* Les dépendances présentes dépendent du pipeline */
774781
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==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.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
sj39wsxtklevsaxiwmynknngk4p2j7c
774782
774781
2026-10-11T01:40:58Z
Mewtow
31375
/* Les dépendances de données */
774782
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
t4qa6uc8iscew59nnutp26otaue1uvw
774783
774782
2026-10-11T01:41:07Z
Mewtow
31375
/* Les schedulers : généralités */
774783
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==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.
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
tfk19lj0fi2oq5pkkfjs4zvvkisfbr0
774787
774783
2026-10-11T01:45:13Z
Mewtow
31375
774787
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==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.
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le renommage de registres
| prevText=Le renommage de registres
| next=Le scoreboarding et l'algorithme de Tomasulo
| nextText=Le scoreboarding et l'algorithme de Tomasulo
}}
</noinclude>
81dwjfp5d6gxso06c1u8d283804b3dy
774791
774787
2026-10-11T01:47:22Z
Mewtow
31375
/* Le replay pipeline du Pentium 4 */
774791
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==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.
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
q8naji68n43mbv2y9lzerbpe3mwllhv
774793
774791
2026-10-11T01:48:12Z
Mewtow
31375
/* Les accès mémoire avec l'exécution dans le désordre */
774793
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
==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.
==Les ''schedulers'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À 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'']]
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.
===La microarchitecture d'un CPU à exécution dans le désordre===
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
==Les accès mémoire avec l'exécution dans le désordre==
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
2r8i2go0iefesii8g2bww0nw49p74mh
774794
774793
2026-10-11T01:48:48Z
Mewtow
31375
774794
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
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. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
==Les accès mémoire avec l'exécution dans le désordre==
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
kq22pfbbplbtrl0cvc24jm90rptog5j
774796
774794
2026-10-11T01:50:57Z
Mewtow
31375
774796
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
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.
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
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.
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 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''.
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
==Les accès mémoire avec l'exécution dans le désordre==
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
snariidlt0m74uuc7wg5g0quwmtncxz
774797
774796
2026-10-11T01:52:29Z
Mewtow
31375
/* La microarchitecture d'un CPU à exécution dans le désordre */
774797
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Lenombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
==Les accès mémoire avec l'exécution dans le désordre==
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
6v1gkmn2py0dfzimfvidxten85mc5qt
774798
774797
2026-10-11T01:52:36Z
Mewtow
31375
/* Lenombre d'instructions en vol augmente */
774798
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Le nombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
==Les accès mémoire avec l'exécution dans le désordre==
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
qsouc4xtc35yg9m7sq6uaym783zf48x
774799
774798
2026-10-11T01:52:51Z
Mewtow
31375
/* Les accès mémoire avec l'exécution dans le désordre */
774799
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Le nombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
8rppybx4x0z1us8vm5oupczfzo96vjh
Fonctionnement d'un ordinateur/Les pipelines dynamiques
0
65886
774595
774582
2026-10-10T12:24:34Z
Mewtow
31375
774595
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
plf5nqym9dvbe874mu7k4dq34vejx7x
774596
774595
2026-10-10T12:24:45Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]]
774595
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
plf5nqym9dvbe874mu7k4dq34vejx7x
774618
774596
2026-10-10T12:52:23Z
Mewtow
31375
/* La fréquence des avals des pipelines multicycle */
774618
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
r06pctzr8r6auaihxrz3g01vltqj226
774619
774618
2026-10-10T12:53:07Z
Mewtow
31375
/* Le contournement avec les registres d'échelonnage */
774619
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
jejcr1bscczttmglwpdd7d4ehqx18cm
774620
774619
2026-10-10T12:53:58Z
Mewtow
31375
/* Le contournement avec un tampon de ré-ordonnancement */
774620
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
miid5cpkr94r12xzkfm4l45ui2r0dgs
774650
774620
2026-10-10T14:34:55Z
Mewtow
31375
/* La spécialisation des avals */
774650
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
===La spécialisation des avals===
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
===Les processeurs à émission simple et multiple===
Toujours est-il que le décodeur 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.
Sur les processeurs dits à '''simple émission''', une instruction est envoyée dans une voie à chaque cycle, soit dans une ALU, soit dans l'unité mémoire, soit dans les voies pour les branchements, etc. Ils peuvent émettre au maximum une instruction apr cycle d'horloge, ils exécutent en moyenne une instruction par cycle. Diverses optimisations comme le renommage de registres et l'exécution dans le désordre permettent de se rapprocher de ce rythme de croisière d'une instruction lancée par cycle.
Mais il existe des processeurs à '''émission multiple''' qui sont capables d'émettre plusieurs instructions à la fois, chacune dans des voies séparées. Les processeurs dits superscalaires sont dans ce cas, mais ils ne sont pas les seuls. Ils permettent de gagner en performance en utilisant au maximum les unités de calcul/voies. Si plusieurs unités de calcul sont inoccupées, un processeur à émission multiple pourra lancer une instruction dans chacune d'entre elles en même temps, ce qui permet de les occuper le plus vite possible. De moins, c'est le cas si les conditions adéquates sont réunies, mais laissons cela pour plus tard. Un chapitre entier sera dédié aux processeurs à émission multiple.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
8n82hinyjj433nd00oocdgw6jdzzrhy
774651
774650
2026-10-10T14:35:27Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774651
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
La quasi-totalité des processeurs modernes dispose d'une voie séparée pour les accès mémoire, une '''unité mémoire''' dédiée. L'unité mémoire est séparée pour une bonne raison : les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
hb2guoudz1txmvunmvej9wcr4pth0f7
774652
774651
2026-10-10T15:51:29Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774652
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
m7zj11jjdttieg6d3nvc54jyky0b6nk
774654
774652
2026-10-10T16:19:28Z
Mewtow
31375
/* Le contournement avec un tampon de ré-ordonnancement */
774654
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
1t6tvwjg5mthz5i0bhymalf8sg9s0wd
774655
774654
2026-10-10T16:24:19Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
774655
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
Elle a été utilisée sur le processeur ARM Cortex A53 et sur son successeur, l'ARM Cortex A510, avec un certain succès.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
8qe9jh5w0c1phn19n1b3oi2pls8p7pk
774656
774655
2026-10-10T16:31:11Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
774656
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
s3sbgz7nqlub50hmeqd08zqyxvz02fq
774662
774656
2026-10-10T16:59:44Z
Mewtow
31375
/* Le contournement avec un tampon de ré-ordonnancement */
774662
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Le RON 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.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
3fmnj7qwhem3ukl3xin7d3589cz7tm6
774663
774662
2026-10-10T17:00:05Z
Mewtow
31375
/* Le contournement avec un tampon de ré-ordonnancement */
774663
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le contournement avec remise en ordre des écritures==
Dans le chapitre sur les pipelines fixe,s nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
===Le contournement avec les registres d'échelonnage===
Voyons ce qu'il en est avec des ''registres d'échelonnage'', en sortie des ALU. Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
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.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
pfb2l6rxb42e2idx7rhnpd9e0wimkwx
774684
774663
2026-10-10T18:13:30Z
Mewtow
31375
/* Le contournement avec remise en ordre des écritures */
774684
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
d7xn7yg5nsj4t14ja9amt7a3btmklxg
774685
774684
2026-10-10T18:13:44Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
774685
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, les dépendances de données posent aussi problème. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond à l'apparition de '''dépendances de donnée de type WAW'''. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
abrnx6qujyvs227p9z5d8qfjzgmepk5
774700
774685
2026-10-10T18:41:25Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774700
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Plus tard dans la suite du cours, nous verrons que les processeurs modernes regroupent plusieurs ALUs par voie, pour des raisons assez compliquées à expliquer ici. Retenez juste que le chemin de données est composé de plusieurs voies relativement indépendantes, chacune spécialisée dans un type de micro-opération spécialisé. Les voies commencent toutes avec la lecture des opérandes, elles effectuent un calcul, puis enregistrent leurs résultats dans les registres si besoin.
Toujours est-il que le décodeur 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.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
jmci06dtppgjiaazl4hgpmxz2o6qxyt
774708
774700
2026-10-10T19:01:35Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774708
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
65u1pr4ajwznrpk19a016e64ca3xd2f
774709
774708
2026-10-10T19:01:58Z
Mewtow
31375
/* L'unité mémoire */
774709
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Les pipelines dynamiques doivent gérer des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Mais pour comprendre pourquoi, il faut voir quelles sont les sources de ces dépendances. Pour résumer, elles se situent au niveau des unités de calcul et du banc de registre.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
cbo72yhlxue9e35b2p9srl6uzulbd7y
774710
774709
2026-10-10T19:03:20Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774710
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps.
Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Par exemple, séparer le cache en un cache d'instruction et un cache de donnée élimine une dépendance structurelle. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
tg2fitzx0dy18uqmtb77jj8jmvr4jrr
774711
774710
2026-10-10T19:04:28Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774711
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
avirvtbfvfzws25oui4uhq7b2ow0l2n
774712
774711
2026-10-10T19:05:05Z
Mewtow
31375
/* Les dépendances structurelles à l'écriture dans le banc de registre */
774712
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
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 gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
96cedh2p8665q7rvt0hd1txpmck7wfe
774714
774712
2026-10-10T19:05:56Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774714
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
9jr4v29pffkt1hjm103p9ayl9wv6fx1
774715
774714
2026-10-10T19:06:10Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774715
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte aussi les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
2kdc36w53y0o3ciu14r78g48gitle45
774716
774715
2026-10-10T19:06:21Z
Mewtow
31375
/* La gestion de la disponibilité des ALUs */
774716
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
===Les registres d'échelonnage===
Pour conserver l'ordre des écritures, la solution la plus simple est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. L'implémentation est plus simple si on suppose que toutes les instructions font le même nombre de cycles. On parle alors d'un pipeline de longueur fixe. Le cout à payer est que les instructions doivent se caler sur l'instruction la plus lente. Si une addition met un cycle à s'exécuter et une multiplication 3, alors toutes les instructions font 3 cycles.
Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de deux cycles. Idem pour les lectures mémoire : si un accès mémoire fait 2 cycles, l'écriture de la donnée lue dans les registres sera retardée d'un cycle. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. 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 multicycles]]
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
enfd9czpyv0k0tqyb9j9c737qg19262
774717
774716
2026-10-10T19:09:18Z
Mewtow
31375
/* Les registres d'échelonnage */
774717
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Les dépendances structurelles à l'écriture dans le banc de registre===
Les instructions multi-cycle lisent et écrivent dans le même banc de registres que les instructions 1-cycle. Le résultat est qu'elles peuvent écrire dans les registres en même temps. Par exemple, imaginons que l'on exécute une multiplication de 6 cycles, suivie au cycle suivant par une opération qui n'en prend que 5. Elles tenteront d'enregistrer leurs données en même temps 6 cycles après pour la première, 1 + 5 cycles pour l'autre. Elles voudront donc enregistre une donnée en même temps. Mais le banc de registre n'a qu'un seul port d'écriture...
La solution la plus simple est que la seconde instruction doit être retardée d'un cycle pour résoudre le problème. En soi, rien de problématique à gérer, l'unité de décodage a juste à insérer des bulles de pipeline. Une autre solution ajoute des ports d'écriture sur le banc de registres, afin que les deux instructions peuvent écrire en même temps dans le banc de registre. Il faut juste qu'elles écrivent dans des registres différents, mais il s'agit d'un cas de dépendance de données, pas une dépendance structurelle proprement dit.
La première solution a un cout en performance, mais est simple à implémenter. Son cout en transistor est limité, tout est concentré dans l'unité de décodage/émission. Pour la seconde solution, son cout en transistor est important, sans compter que le banc de registre devient alors plus gourmand en énergie. Le tout est à comparer à un gain en performance généralement limité. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
fl451xrpoo789eco8dcu01atq432ys4
774719
774717
2026-10-10T19:13:37Z
Mewtow
31375
/* Les dépendances structurelles à l'écriture dans le banc de registre */
774719
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
8idjisfg41df3m5xgzvonc7ogkhd3dq
774720
774719
2026-10-10T19:17:49Z
Mewtow
31375
/* Le contournement avec remise en ordre des écritures */
774720
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Plusieurs micro-opérations peuvent écrire en même temps dans le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
2kqcje56003irn6w5s87s0q3pnsvqhy
774721
774720
2026-10-10T19:18:24Z
Mewtow
31375
/* Le commit multiple dans le ROB */
774721
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent deux techniques. Premièrement, ils utilisent une file d'écriture pour les écritures en mémoire RAM. Deuxièmement, pour les écritures dans les registres, ils utilisent des blocages de pipeline, histoire de retarder certaines instructions, et les écritures associées.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
963lu8sbvn97wa9wp10j72dgoegfhno
774722
774721
2026-10-10T19:23:13Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774722
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec remise en ordre des écritures===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
09q2u0lvkimhs7rfif4ru27lrqaifhl
774723
774722
2026-10-10T19:24:24Z
Mewtow
31375
/* Le contournement avec remise en ordre des écritures */
774723
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
s47aj2lpwvqj9e5ojppat705mukv2h8
774724
774723
2026-10-10T19:36:25Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774724
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
fptivt3pfvj6z14wrcajb789gseo0ve
774725
774724
2026-10-10T19:37:00Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774725
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec un aval et un amont (back-end et front-end).]]
Si on omet les voies liées aux accès mémoire, les autres voies correspondent peu ou prou à une unité de calcul. 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.
[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
sgi32exzggtcjgcdbvejezioqazvs5a
774726
774725
2026-10-10T19:42:00Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774726
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec avals et un amont (back-end et front-end).]]
|[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
|}
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
===L'unité mémoire et les calculs d'adresse===
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire prend en entrée l'adresse à lire/écrire, éventuellement la donnée à écrire. Si elle contient une AGU pour calculer les adresses, les indices sont envoyés sur une entrée dédiée. En sortie, elle fournit la donnée lue. Les entrées et sorties sont reliées au réseau de contournement, ainsi qu'aux registres. L'adresse est soit lue dans les registres, soit fournie par le réseau de contournement, soit calculée par l'AGU intégrée à l'unité mémoire elle-même.
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré, dans un pipeline.]]
L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
fsipci0a72lngw24c8m375j56yue4u1
774727
774726
2026-10-10T19:44:20Z
Mewtow
31375
/* L'unité mémoire et les calculs d'adresse */
774727
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec avals et un amont (back-end et front-end).]]
|[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
|}
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
Les micro-opérations mémoires sont gérées par l''''unité mémoire''', ou encore la ''Load-Store Unit''. Pour le moment, l'unité mémoire est une sorte de boite noire, dont on ne détaillera pas l'intérieur. L'unité d'émission la considère d'ailleurs comme telle. L'unité mémoire est séparée des autres unités parce que les accès mémoire se marient assez mal avec un pipeline. Les accès mémoire ont une latence élevée, variable. Ils sont difficiles à pipeliner de manière générale. La voie pour les accès mémoire sert donc d'exception, c'est une unité qui n'est pas tout à fait pipelinée, alors que le reste du chemin de données l'est. Sa connexion avec le pipeline varie grandement suivant le processeur.
===L'émission des instructions dans les voies===
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
16s8ekdjoh70aqh3izumld3bnps5sxx
774729
774727
2026-10-10T19:46:07Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774729
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
==Les pipelines dynamiques séparent ''front-end'' et ''back-end''==
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec avals et un amont (back-end et front-end).]]
|[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
|}
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
ollmohqbzfr8dg8xbc3zurc32qxcewi
774730
774729
2026-10-10T19:46:25Z
Mewtow
31375
/* Les pipelines dynamiques séparent front-end et back-end */
774730
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec avals et un amont (back-end et front-end).]]
|[[File:Pipeline à deux aval de type load-store.png|centre|vignette|upright=1.5|Pipeline à deux aval de type load-store.]]
|}
La destination de chaque voie peut varier. Les instructions de calcul ou les lectures enregistrent un résultat dans les registres, et se terminent donc sur le port d'écriture du banc de registres. Par contre, les voies pour les branchements ou les écritures n'enregistrent rien dans les registres. La voie pour les branchements se termine dans le séquenceur et/ou dans le registre d'état, la voie pour les écritures ne finit nulle part si ce n'est dans l'unité mémoire.
[[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|centre|vignette|upright=1.5|Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements]]
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
fi4nom4szg7oyh0usyo6835yx61kf8a
774731
774730
2026-10-10T19:49:40Z
Mewtow
31375
774731
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. D'autres solutions sont possibles, voyons lesquelles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
bwy04kq16w4mq9ncb82npufzcox180d
774761
774731
2026-10-11T01:20:15Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774761
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===L'unité d'émission et le blocage du pipeline===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Avec l'implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
0h6oubrxxivi37w6ng4n091gxqzfsms
774769
774761
2026-10-11T01:26:20Z
Mewtow
31375
/* L'unité d'émission et le blocage du pipeline */
774769
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===L'unité d'émission et le blocage du pipeline===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'émission dans l'ordre des instructions
| nextText=L'émission dans l'ordre des instructions
}}
</noinclude>
ahpyrin8v6h6e7riti9czkj6av6isiq
774770
774769
2026-10-11T01:27:05Z
Mewtow
31375
/* Les unités de calcul à double fréquence du Pentium 4 */
774770
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===L'unité d'émission et le blocage du pipeline===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il s’applique en absence de branchements et peut être vu comme une amélioration des lectures non-bloquantes. Le mécanisme s'active quand une lecture est bloquée par un défaut de cache ou toute autre situation. Le processeur implémente l'exécution dans le désordre, ce qui fait qu'il exécute les instructions qui suivent une lecture bloquée, à condition qu'elles ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en attente. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
lvew6ft6187eafx1bhl6fp346picy8m
774800
774770
2026-10-11T02:10:44Z
Mewtow
31375
/* La complétion dans le désordre */
774800
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===L'unité d'émission et le blocage du pipeline===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
5atd7qsqukea505w0qw9sydb6f2va6v
774802
774800
2026-10-11T02:35:23Z
Mewtow
31375
/* L'unité d'émission et le blocage du pipeline */
774802
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===L'unité d'émission et le blocage du pipeline===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalagesz en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité démission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Dans ses versions plus élaborées, ce ''Result Shift Register'' contient, pour chaque instruction envoyée dans le pipeline, non seulement un bit qui précise si l'étage est occupé, mais aussi des informations sur l'instruction. L'unité de calcul qu'elle utilise, le registre dans lequel elle veut stocker son résultat, et l'adresse de l'instruction. Ces informations permettent de piloter l'écriture des résultats de nos instructions en mémoire, et sont utiles pour d'unité de décodage : cette dernière a besoin d’informations pour savoir s'il faut bloquer le pipeline.
Par exemple, on stocke l'adresse de l'instruction pour remettre le Program Counter à sa bonne valeur, si une exception arrive. Et oui, ce Program Counter est modifié automatiquement par l'unité de Fetch à chaque cycle. Et si une exception arrive, il faut trouver un moyen de remettre le Program Counter à la bonne valeur : celle de l'instruction qui a levée l'exception, afin de pouvoir savoir où brancher au retour de l'exception.
On stocke le registre de destination pour savoir dans quel registre cette instruction doit écrire, et aussi pour savoir quels sont les registres utilisés par les instructions en cours dans le pipeline. J'avais dit plus haut que pour l'unité de décodage devait vérifier si l'instruction qu'elle s’apprête à lancer va lire une donnée écrite pas une instruction encore dans le pipeline. Si c'est le cas, l'unité de décodage doit alors insérer des Pipeline Bubbles. Et bien la liste des registres écrit pas les instructions présentes dans notre pipeline se trouve dans ce Result Shift Register.
En tout cas, notre Result Shift Register est un peu plus complexe. Celui-ci est composé d'entrées, des espèces de blocs de mémoire qui stockent toutes les informations pour une instruction. Unité de calcul utilisée, registre de destination, Program Counter, et bit indiquant que l'étage est occupé. Auparavant, avec la version simplifiée du ''Result Shift Register'', seul les bits indiquant l'occupation d'un étage étaient décalés vers la droite. Mais avec la version évoluée du ''Result Shift Register'', ce sont toutes les entrées qui sont décalées d'un cran à chaque cycle d'horloge.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
6dco01xufdzo8r456zzupo7xsi4kc13
774803
774802
2026-10-11T02:37:29Z
Mewtow
31375
/* L'unité d'émission et le blocage du pipeline */
774803
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalagesz en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité démission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Dans ses versions plus élaborées, ce ''Result Shift Register'' contient, pour chaque instruction envoyée dans le pipeline, non seulement un bit qui précise si l'étage est occupé, mais aussi des informations sur l'instruction. L'unité de calcul qu'elle utilise, le registre dans lequel elle veut stocker son résultat, et l'adresse de l'instruction. Ces informations permettent de piloter l'écriture des résultats de nos instructions en mémoire, et sont utiles pour d'unité de décodage : cette dernière a besoin d’informations pour savoir s'il faut bloquer le pipeline.
Par exemple, on stocke l'adresse de l'instruction pour remettre le Program Counter à sa bonne valeur, si une exception arrive. Et oui, ce Program Counter est modifié automatiquement par l'unité de Fetch à chaque cycle. Et si une exception arrive, il faut trouver un moyen de remettre le Program Counter à la bonne valeur : celle de l'instruction qui a levée l'exception, afin de pouvoir savoir où brancher au retour de l'exception.
On stocke le registre de destination pour savoir dans quel registre cette instruction doit écrire, et aussi pour savoir quels sont les registres utilisés par les instructions en cours dans le pipeline. J'avais dit plus haut que pour l'unité de décodage devait vérifier si l'instruction qu'elle s’apprête à lancer va lire une donnée écrite pas une instruction encore dans le pipeline. Si c'est le cas, l'unité de décodage doit alors insérer des Pipeline Bubbles. Et bien la liste des registres écrit pas les instructions présentes dans notre pipeline se trouve dans ce Result Shift Register.
En tout cas, notre Result Shift Register est un peu plus complexe. Celui-ci est composé d'entrées, des espèces de blocs de mémoire qui stockent toutes les informations pour une instruction. Unité de calcul utilisée, registre de destination, Program Counter, et bit indiquant que l'étage est occupé. Auparavant, avec la version simplifiée du ''Result Shift Register'', seul les bits indiquant l'occupation d'un étage étaient décalés vers la droite. Mais avec la version évoluée du ''Result Shift Register'', ce sont toutes les entrées qui sont décalées d'un cran à chaque cycle d'horloge.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
j1bjnu07tijufa62wfm38owidhyodz7
774804
774803
2026-10-11T02:38:03Z
Mewtow
31375
/* Le blocage du pipeline avec un result shift register */
774804
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalagesz en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité démission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
0dncd39sb9bkfs9bw694v4hf0hzyx1x
774805
774804
2026-10-11T02:40:17Z
Mewtow
31375
/* Le blocage du pipeline avec un result shift register */
774805
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Sshift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1 ||
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité démission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
kxcf63m8xg4go5t65zn8oko0ml9ynr2
774806
774805
2026-10-11T02:40:34Z
Mewtow
31375
/* Le blocage du pipeline avec un result shift register */
774806
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité démission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
mndotr5vehdfhsfque8r33l9ie9zw3m
774807
774806
2026-10-11T02:44:07Z
Mewtow
31375
/* Le blocage du pipeline avec un result shift register */
774807
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
d48ucm5oa03utdg9r9lxn5e7un73phl
774808
774807
2026-10-11T02:49:31Z
Mewtow
31375
/* Le commit multiple dans le ROB */
774808
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, la solution est de '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le blocage du pipeline avec un ''result shift register''===
Une première solution améliore l'unité d'émission pour qu'elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
tgnqaa44xqvhms6jwj2p92yynlyyuoc
Fonctionnement d'un ordinateur/Le pipeline
0
65892
774603
774584
2026-10-10T12:33:16Z
Mewtow
31375
774603
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuetr une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
tlw3sps63qfyz0l08uzxlthemm68lb4
774606
774603
2026-10-10T12:33:59Z
Mewtow
31375
/* Le monde réel : l'exemple des processeurs Intel */
774606
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuetr une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
r9p2mpxcrf6o48vof0dmufby8hwbga4
774688
774606
2026-10-10T18:25:21Z
Mewtow
31375
/* Les étages du pipeline */
774688
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
m8qr2fafbhhrgoppmy0nu0s3vqw1ym6
Fonctionnement d'un ordinateur/La prédiction de branchement
0
65955
774605
774416
2026-10-10T12:33:53Z
Mewtow
31375
/* L'usage de fonctions de hashage pour indexer les diverses SRAM/tables */
774605
wikitext
text/x-wiki
Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination.
L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution.
Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée.
[[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]]
Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct.
==Les branchements et les exceptions imprécises/précises==
Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline.
Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris.
Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort.
Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours.
===L'annulation des instructions au décodage===
Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes.
Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage.
===L'usage du système d'exceptions précises pour les branchements conditionnels===
La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci.
L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris.
Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements.
===La prédiction de branchement===
Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes :
* reconnaître les branchements ;
* savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ;
* dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''.
Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit.
[[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]]
: Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''.
Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects.
Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Un premier prédicteur fournit des résultats rapides et peu précis, un second a des résultats plus précis après quelques cycles d'horloge. Le prédicteur rapide envoit l'adresse prédite au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter''. Le prédicteur lent envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris.
Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. Ce détail aura rapidement son importance.
==Les erreurs de prédiction de branchement==
Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres.
Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée.
[[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]]
===La pénalité de mauvaise prédiction===
Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres
Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]]
Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement.
===La ''minimal control dependency''===
En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées.
Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin.
Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées.
Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction.
[[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]]
==La reconnaissance des branchements==
Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution.
La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache.
Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache
Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses.
* La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris.
* L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM.
L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit.
L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement.
==La prédiction de l'adresse de destination==
La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile).
: Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''.
Pour rappel, les branchements se classent en trois types :
* les branchements directs pour lequel l'adresse de destination est toujours la même ;
* les branchements indirects où l'adresse de destination est dans un registre et peut changer ;
* les instructions de retour de fonction.
Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux.
===La prédiction de destination pour les branchements directs===
Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects.
Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas.
Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents.
[[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]]
Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache.
Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB.
Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''.
Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées.
Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''.
Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant.
Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort.
[[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]]
Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1.
Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage.
{|class="wikitable"
|-
! rowspan="3" | Zen 2
| 16 || 0
|-
| 512 || 1
|-
| 7168 || 4
|-
! rowspan="3" | Zen 3
| 16 || 0
|-
| 1024 || 1
|-
| 6656 + 1536 (branchements indirects)|| 4
|}
===La prédiction de destination pour les branchements indirects===
Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement.
Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne.
Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique.
{|class="wikitable"
|+ Branch Target Buffer unifié
|-
! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination
|-
| 0x54462146 || 11101011 || 0x52495674 || 0x24987461
|-
| 0x54142446 || 11101101 || 0x15646956 || x0x4158746
|-
| 0x25456542 || 10011101 || 0x52495674 || 0x24157461
|-
| 0x15455855 || 11010101 || 0x52485754 || 0x24024511
|-
| ... || ... || ... || ...
|-
| ... || ... || ... || ...
|-
| 0x54462146 || 1001101 || 0x52495674 || 0x24987461
|}
Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement.
[[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]]
L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides.
Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations.
Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité.
Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ?
La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente.
La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement.
L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris.
Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord.
Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération.
Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus.
[[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]]
L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux.
===La prédiction de l'adresse de retour d'une fonction===
Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours.
Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour.
Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur.
La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse.
Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions.
La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction.
La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé.
Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit.
==La prédiction de branchement locale==
La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''.
La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses.
La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas.
Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent.
Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques.
La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales.
===Les ''branch hints''===
Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal.
La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre.
===La prédiction statique de branchement===
La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire.
Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique.
Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants.
Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit :
* les branchements inconditionnels sont toujours pris ;
* les branchements descendants sont toujours pris ;
* les branchements ascendants ne sont jamais pris.
L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''.
{|class="wikitable"
|+ BTB avec prédiction de branchement statique évoluée
|-
! Adresse du branchement !! Adresse de destination !! Type du branchement
|-
| 0x4165485 || 0x14657445541
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Retour de fonction
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ... || ...
|}
===La prédiction avec des compteurs à saturation===
Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales.
Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante.
Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi.
Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%.
[[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]]
: Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale.
La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements.
L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB.
===La prédiction avec un historique local===
La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions.
Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique.
Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions.
Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception.
Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent.
Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites.
Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter.
Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit.
Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT.
[[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]]
Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''.
Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC.
[[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]]
Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux.
==La prédiction de branchement globale/mixte==
Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement.
L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction.
L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local.
[[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]]
Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux.
Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux.
[[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]]
Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances !
Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail.
===La mitigation des interférences par usage de l'adresse de branchement===
La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat.
Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique.
Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors.
[[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]]
D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées.
Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser.
[[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]]
Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''.
[[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]]
Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations.
Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation.
Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides.
Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement.
[[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]]
Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées.
[[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]]
===La mitigation des interférences par la structuration en caches des PHTs===
Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches.
Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique.
La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible.
Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels.
===La mitigation des interférences liées aux branchements biaisés===
L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale.
En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit.
Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''.
L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant.
[[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]]
La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale.
Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement.
[[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]]
Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris.
==La prédiction de branchement avec des perceptrons==
Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''.
Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables.
===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements===
L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même.
Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1.
Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci :
: <math>Z = w_0 + \sum_i (h_i \times w_i)</math>
[[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]]
: Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids.
Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante :
: <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris.
===L'implémentation matérielle des perceptrons et ses optimisations===
Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande.
Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle.
[[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]]
Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide.
Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM.
D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables.
Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit).
===Les avantages et inconvénients des perceptrons pour la prédiction de branchements===
L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent.
Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements.
La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante :
: <math>w_0 + \sum_i (h_i \times w_i) = 0</math>
Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes.
Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales.
Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure.
Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours.
==La prédiction des branchements des boucles==
Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois.
Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle.
Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance".
Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois.
Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité.
==La prédiction avec plusieurs unités de branchement distinctes==
Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions.
La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique.
Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité.
L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité.
[[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]]
Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier.
===Le prédicteur L-TAGE===
Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats.
Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique.
Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien.
Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près.
Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit.
==Les optimisations de la prédiction de branchements==
La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc.
===Le filtrage de branchements===
Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements.
Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions.
===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables===
Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées.
Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques.
Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers.
L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines fixes
| prevText=Les pipelines fixes
| next=Les optimisations du chargement des instructions
| nextText=Les optimisations du chargement des instructions
}}
</noinclude>
a78t06gjbja45isoczwy9uw497twflv
Fonctionnement d'un ordinateur/Les architectures parallèles
0
65958
774778
748557
2026-10-11T01:29:42Z
Mewtow
31375
/* Le parallélisme de données : la loi de Gustafson */
774778
wikitext
text/x-wiki
De nos jours, les processeurs avec plusieurs cœurs sont devenus la norme. L'utilité de tels processeurs est très simple : la performance ! Ils exécutent des instructions indépendantes dans des processeurs séparés, ce qui porte le doux nom de '''parallélisme'''. Mais les processeurs multicœurs ne sont pas les seuls processeurs permettant de faire ceci. Entre les ordinateurs embarquant plusieurs processeurs, les architectures dataflow, les processeurs vectoriels et les autres architectures parallèles, il y a de quoi être perdu. Pour se faciliter la tâche, diverses classifications ont été inventées pour mettre un peu d'ordre dans cette jungle de processeurs.
==Le partage de la mémoire==
On peut classer les architectures parallèles suivant comment se partage la mémoire entre les processeurs. Pour simplifier, il existe deux méthodes simples pour partager la mémoire. La première est la mémoire partagée, l'autre est celle des mémoires distribuées, avec une méthode intermédiaire.
La première méthode utilise une '''mémoire partagée''' entre tous les processeurs. Les processeurs peuvent alors entrer en conflit pour l'accès à la mémoire, ce qu'il convient de gérer au mieux. Par contre, l'implémentation peut être très efficace, particulièrement sur les architectures multicœurs.
[[File:Mémoire partagée.png|centre|vignette|upright=1.5|Architecture à mémoire partagée.]]
Viennent ensuite les '''architectures switchées''', où plusieurs processeurs sont reliés à plusieurs mémoires par l'intermédiaire d'un réseau de type ''crossbar''. Grâce à l'interconnexion ''crossbar'', tout processeur peut accéder à toutes les mémoires. Elle est possible avec un petit nombre de processeurs, où elle permet de réduire les conflits d'accès à la mémoire. Par contre, le temps d'accès à la mémoire est augmenté, le réseau d'interconnexion entre processeur et mémoire ajoutant un délai non-négligeable.
[[File:SMP Multiprocessor with crossbar switch.svg|centre|vignette|upright=2|Architecture hybride à interconnexion ''crossbar''.]]
Les '''architectures distribuées''' interconnectent plusieurs ordinateurs séparés avec un réseau local. Avec elles, chaque processeur possède sa propre mémoire rien que pour lui, qu'il est le seul à pouvoir l'adresser directement. Si un autre processeur veut accéder au contenu de sa mémoire, il doit passer par une transmission sur le réseau local. Les conflits d'accès à la mémoire disparaissent, mais l'implémentation est alors plus compliquée et les transferts sur le réseau ont un cout en performance important.
[[File:Architecture distribuée.png|centre|vignette|upright=2|Architecture distribuée.]]
D'autres architectures sont des intermédiaires entre mémoire partagée et architectures distribuées. Par exemple, sur un système à 8 processeurs, on peut avoir deux ordinateurs reliés par un réseau, mais avec chaque ordinateur ayant une mémoire partagée entre 4 processeurs.
[[File:NUMA.svg|centre|vignette|upright=2|Architecture hybride.]]
===Les avantages et inconvénients de la mémoire partagée/distribuée===
Concrètement, les trois méthodes sont utilisées dans des cas bien différents. Les deux premières marchent bien avec un petit nombre de processeurs, alors que les architectures distribuées sont adaptées à un grand nombre de processeurs. Par contre, la mémoire partagée est un peu à part en termes de performances, comparé aux deux autres méthodes.
La raison est que partager un bus unique entre un grand nombre de processeurs ne marche pas très bien. Le débit binaire du bus est partagé entre tous les processeurs. Toutes choses égales par ailleurs, plus on ajoute de processeurs, moins chaque processeur aura de débit alloué. Et le débit binaire des bus a des limites rapidement atteintes, ce qui fait que la mémoire partagée est rarement mise en œuvre au-delà d'une dizaine de processeurs. Mais en utilisant des mémoires séparées, les deux mémoires seront accédées en parallèle, les deux processeurs n'entreront pas en conflit pour l'accès à la mémoire.
Niveau performances, les deux solutions font face à des défis différents. Exécuter beaucoup d'opérations sur des processeurs différents demande d'alimenter ces opérations en opérandes, ce qui implique une mémoire RAM avec un débit binaire suffisant. Dans le chapitre sur la performance d'un ordinateur, nous avions vu le ''roofline model'', qui nous disait que dans certaines conditions, les applications voient leur performance limitée par le débit de la mémoire.
[[File:Roofline model.png|centre|vignette|upright=2.5|Roofline model]]
Et c'est un point où les mémoires partagées n'aident pas. Avec une mémoire partagée, le débit de la mémoire est partagé entre plusieurs cœurs/processeurs, ce qui fait qu'il devient un facteur limitant assez rapidement. Plus on cherche à exécuter de programmes/tâches/''threads'' avec une intensité calculatoire fixe, plus on utilisera de débit mémoire et plus on se rapproche de ce point limitant. Par contre, avec une architecture distribuée, on n'a pas ce problème. Chaque processeur a sa propre mémoire, le débit binaire de sa mémoire lui est totalement dédié. Pour le dire autrement, le débit binaire total augmente avec le nombre de mémoires dans une architecture distribué, alors qu'il est fixe avec une mémoire partagée.
Un autre problème est celui des couts de synchronisation et d'arbitrage. Avec la mémoire partagée, rien n’empêche plusieurs processeurs de vouloir accéder à la mémoire en même temps, ce qui demande d'implémenter de quoi arbitrer les accès à la mémoire entre les processeurs. Les couts de synchronisation peuvent être conséquents et peuvent réduire les performances. Avec une architecture distribuée, les processeurs ne se marchent pas sur les pieds en accédant à la mémoire. Par contre, les transferts entre processeurs sont plutôt lents. Les processeurs peuvent accéder à la mémoire d'un autre processeur via le réseau local, qui permet les transferts entre processeurs. Les communications entre les différents processeurs peuvent prendre un temps relativement long, loin d'être négligeable. Il va de soi que la performance du réseau est primordiale : plus le réseau est rapide, meilleures seront les performances.
===Le partage de l'espace d'adressage===
Le partage de la mémoire impacte aussi un autre point très important : l'espace d'adressage des processeurs. Pour rappel, l'espace d'adressage d'un processeur 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 = 65 536 adresses et l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas la mémoire réellement installée : s'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques.
Quand on dispose de plusieurs processeurs, deux possibilités surviennent : soit ils ont chacun leur espace d'adressage, soit ils partagent le même espace d'adressage. La mémoire partagée partage l'espace d'adressage sur plusieurs processeurs, pas d'erreur là-dessus. Les architectures switchées ont aussi un espace d'adressage partagé entre tous les processeurs. Logique : sans cela, ils n'auraient aucun intérêt à avoir un ''crossbar'' pour les relier à toutes les mémoires RAM. Pour résumer : si plusieurs CPU accèdent à la même mémoire, ils partagent leur espace d'adressage.
Sur les architectures distribuées, on s'attend à ce que chaque processeur ait son propre espace d'adressage, adossé à sa mémoire privée, sa mémoire locale. Mais ce n'est pas le cas pour un sous-type d'architectures distribuées, appelé les '''architectures NUMA''' (''Non Uniform Memory Architecture''). Avec elles, tous les processeurs voient un espace d'adressage global, qui contient les différentes mémoires locales. Lors des accès mémoire, les adresses utilisées sont des adresses de l'espace d'adressage global, de la totalité des mémoires. Si l'adresse est dans la mémoire locale du processeur, l'accès est direct. Dans le cas contraire, le système d'exploitation prend la relève et prépare une communication sur le réseau. Bien tirer parti de ces architectures demande de placer le plus possible les données en mémoire locale : l'idée est d'éviter les accès au réseau, qui sont lents.
[[File:Espace d'adressage d'une architecture NUMA.png|centre|vignette|upright=2|Espace d'adressage d'une architecture NUMA.]]
Sur les architectures NUMA, migrer les données à la main peut aboutir à des migrations inutiles, le processeur n'ayant pas besoin des données transférées. Pour éviter ces copies inutiles, le meilleur moyen est de les faire à la demande. En faisant ainsi, chaque mémoire locale se comporte comme un cache, mais sans qu'il y ait de mémoire principale. On obtient alors une '''architecture COMA''' : ''Cache Only Memory Architecture''. Ces architectures font cependant face à quelques problèmes. Premièrement, il faut pouvoir localiser la mémoire qui contient le bloc demandé à partir de l'adresse de ce bloc. Ensuite, on doit gérer la migration de blocs dans une mémoire locale pleine. Sur les architectures Simple-COMA, ces opérations sont déléguées au système d'exploitation. Seules les architectures Flat-COMA et Hierarchical-COMA effectuent ces opérations en matériel, en modifiant les circuits de cohérence des caches. La différence entre ces deux architectures tient dans le réseau d'interconnexion utilisé pour relier les processeurs.
Pour résumer :
{|class="wikitable"
|-
!
! Mémoire partagée
! Architecture switchée
! Architecture distrbuée
|-
! Espace d'adressage partagé
| Mémoire partagée
| Architecture switchée
| Architecture NUMA/COMA
|-
! Espace d'adressage séparé
|
|
| Architecture distribuée
|}
==La taxonomie de Flynn==
Une autre classification, très connue dans le monde du hardware, est la classification de Flynn, du nom du chercheur qui l'a popularisée. La taxinomie de Flynn n'est pas parfaite, dans le sens où certaines architectures sont difficiles à classer. Mais cette classification, bien que simpliste, est particulièrement didactique et a remarquablement tenu le coup au fil du temps. La première version a été publiée en 1966, ce qui date d'avant l'invention des architectures dites vectorielles ou des architectures à ''multithreading'' matériel. Une seconde version a été publiée en 1972, soit un an après l'invention des premières architectures vectorielles, ce qui fait que nous allons voir cette dernière. Elle se base sur deux critères : le nombre de programmes exécutés en même temps, la quantité de données traitées en parallèles.
===Les architectures SISD, SIMD, MISD et MIMD===
En combinant les deux critères précédents, la classification de Flynn classe les architectures parallèles en 4 grandes catégories : SISD, SIMD, MISD, et MIMD. Décortiquons ces acronymes. SI signifie que le processeur ne peut exécuter qu'une seule instruction à la fois, MI qu'il peut en exécuter plusieurs : ce sont les abréviations de ''Single Instruction'', ''Multiple Instruction''. SD signifie que le processeur ne traite qu'une donnée à la fois, MD plusieurs : ce sont les abréviations de ''Single Data'', ''Multiple Data''.
{|class="wikitable" center
|-
! \
! Données traitées en série
! Données traitées en parallèle
|-
! Instructions traitées en série
| SISD
| SIMD
|-
! Instructions traitées en parallèle
| MISD
| MIMD
|}
Les '''architectures SISD''' sont incapables de toute forme de parallélisme.
Les '''architectures SIMD''' exécutent une instruction sur plusieurs données à la fois. Avec cette solution, les processeurs exécutent un seul et unique programme ou une suite d'instructions, mais chaque processeur travaille sur une donnée différente.
[[File:SIMD - taxonomie de Flynn.png|centre|vignette|upright=2|SIMD - taxonomie de Flynn.]]
Les '''architectures MISD''' exécutent des instructions différentes en parallèle sur une donnée identique. Les architectures MISD sont vraiment très rares.
[[File:MISD - taxonomie de Flynn.png|centre|vignette|upright=2|MISD - taxonomie de Flynn.]]
Les '''architectures MIMD''' exécutent des instructions différentes sur des données différentes. La catégorie MIMD est elle-même découpée en deux sous-catégories.
* Le '''Single Program Multiple Data''' , ou SPMD, consiste à exécuter un seul programme sur plusieurs données à la fois : la différence avec le SIMD est que le SPMD peut exécuter des instructions différentes d'un même programme sur des données. Dès qu'il y a des conditions et des branchements, deux programmes peuvent diverger et ne plus s'executer à l'identique.
* Le '''Multiple Program Multiple Data''', qui consiste à exécuter des programmes en parallèle sur des données différentes. On peut ainsi découper un programme en sous-programmes indépendants exécutables en parallèle ou exécuter plusieurs copies d'un programme. Dans les deux cas, chaque copie ou morceau de programme est appelé un thread.
[[File:MIMD - taxonomie de Flynn.png|centre|vignette|upright=2|MIMD - taxonomie de Flynn.]]
===Une description détaillée des architectures SIMD et MIMD===
Dans ce qui va suivre, ce sont surtout les catégories MIMD et SIMD qui vont nous intéresser. La catégorie SIMD regroupe de nombreuses architectures différentes, qui seront vues dans le chapitre sur les architectures à parallélisme de données. La classification de Flynn distinguait les processeurs vectoriels, les ''array processors'' et les architectures associatives. Mais la classification de Flynn originale oublie certaines architectures SIMD inventées après elle. Par exemple, la quasi-totalité des processeurs modernes incorpore des instructions SIMD pour en font techniquement des hybrides SIMD-SISD.
Nous avons déjà parlé des architectures associatives, dans le chapitre sur les mémoires associatives. Elles sont composées d'une mémoire associative améliorée, où chaque case mémoire est reliée à une unité de calcul. Toutes les unités de calcul exécutent la même instruction en même temps : on envoie une instruction à la mémoire associative, elle l'envoie à toutes les ALU et l'exécute. Mais elles sont très rares, seule une dizaine de processeurs de ce type ont été réellement implémentés, avant les années 90, sans aucun projet récent. Les processeurs vectoriels et les ''array processors'' seront vus en détail dans le chapitre sur le parallélisme de données, avec les instructions SIMD. Nous ne pouvons pas parler de leurs différences ici, car cela demanderait de rentrer dans le vif du sujet.
Les architectures MIMD utilisent systématiquement plusieurs processeurs. Elles regroupe deux sous-catégories principales, qui se distingue sur un critère : l'usage soit de la mémoire partagée, soit de la mémoire distribuée. La première catégorie regroupe les processeurs multicœurs et multiprocesseurs. Pour rappel, ils exécutent un programme respectivement sur chaque cœur ou sur chaque processeur, avec une mémoire RAM partagée. Les systèmes distribués, où plusieurs ordinateurs sont reliés entre par des réseaux locaux, sont à l'opposé et forment une seconde sous-catégorie d'architecture MIMD. Ils n'utilisent pas la mémoire partagée, mais ont bien plusieurs processeurs.
Il faut cependant noter que certains projets de recherche ont étudié des architectures MIMD capables de découper un programme en ''threads'' à l’exécution, éventuellement grâce à des indications fournies par le programme lui-même ! C'est tout de même assez rare, même si cela a déjà été tenté : on reviendra dessus quand je parlerai des architectures EDGE et du ''speculative multithreading''.
==Le parallélisme de données et de tâches==
Les architectures SIMD et SPMD effectuent ce qu'on appelle du '''parallélisme de données''', à savoir exécuter un même programme sur des données différentes, en parallèle. La quasi-totalité des processeurs est aujourd'hui de ce type, qu'il s'agisse des processeurs Intel et AMD actuels ou de leurs confrères de chez ARM, MIPS et VIA. Le parallélisme de donnée est aussi massivement utilisé dans les cartes graphiques : chaque calcul sur un pixel est plus ou moins indépendant des transformations qu'on effectue sur ses voisins.
Le parallélisme de données s'oppose au '''parallélisme de tâches''', qui exécute plusieurs programmes en parallèle. Il s'exploite en découpant un programme en sous-programmes indépendants appelés des '''''threads''''', qui sont exécutés en parallèle. Le découpage d'un programme en threads est avant tout un problème logiciel, du ressort du compilateur, du programmeur, voire du langage de programmation. Les architectures permettant d’exécuter des threads en parallèle sont les architectures multiprocesseurs ou multicœurs, ainsi que quelques autres processeurs un peu spéciaux.
Le parallélisme de données et le parallélisme de tâches n'ont pas du tout la même efficacité, comme nous allons le voir. Pour faire la comparaison entre les deux, nous allons voir la loi d'Amdhal, qui décrit le parallélisme de tâche, et la loi de Gustafson, qui décrit le parallélisme de données.
===Le parallélisme de tâches : la loi d'Amdhal===
La loi d'Amdhal donne l’amélioration maximale que l'on peut obtenir d'un programme parallèle, quand il s’exécute sur plusieurs processeurs. Pour la calculer, on suppose que ce code parallèle peut tout aussi bien exploiter la puissance d'un seul processeur, que de 2, 20, voir 10 000 000 processeurs. Bref, le code est quasiment parfait et on s'interdit les situations du style : on observe un gain énorme avec 2 ou 4 processeurs, mais pas au-delà. De plus, ce programme est exécuté sur la même quantité de données : on ne rajoute pas de données en même temps qu'on ajoute des processeurs, et ces données ont toujours la même taille, histoire de comparer ce qui est comparable.
La limite du parallélisme de tâches tient dans le fait que tous les programmes ne peuvent pas utiliser plusieurs processeurs à la perfection. Tout programme conserve une certaine portion de code qui ne profite pas du parallélisme. Appelons cette portion de code le ''code série'', tandis que la portion de code qui profite du parallélisme sera appelée le ''code parallèle''. Le ou les processeurs passeront un certain temps à exécuter le code série, et un autre temps dans le code parallèle. Notons ces deux temps <math>T_s</math> et <math>T_p</math>. On sait que le temps <math>T</math> mis par un programme à s'exécuter vaut donc :
: <math>T = T_s + T_p</math>
Seconde hypothèse : le code parallèle profite au mieux de la présence de plusieurs processeurs. Dit autrement, si on possède <math>N</math> processeurs, tous auront une part égale du code parallèle, en temps d'exécution. Dans ces conditions, le temps d'exécution <math>T</math> du programme devient celui-ci :
: <math>T_\text{parallele} = T_s + \frac{T_p}{N}</math>
Divisons par T :
: <math>\frac{T_\text{parallele}}{T} = \left( T_s + \frac{T_p}{N} \right) \frac{1}{T} = \frac{T_s}{T} + \frac{T_p}{T}\frac{1}{N}</math>
Maintenant, notons <math>S</math> et <math>P</math> le pourcentage de temps d’exécution passé respectivement dans le code série et dans le code parallèle. Par définition, ces deux pourcentages valent <math>\frac{T_s}{T}</math> et <math>\frac{T_p}{T}</math>. Dans l'équation précédente, on peut identifier ces deux pourcentages (leur inverse, précisément). On a alors, en faisant le remplacement :
: <math>\frac{T_\text{parallele}}{T} = S + \frac{P}{N}</math>
Cette formule nous permet de calculer le gain obtenu grâce au parallélisme. Ce gain se calcule en divisant le temps avant parallélisation et le temps obtenu après. C'est intuitif : si un programme va deux fois plus vite, c'est que son temps d’exécution a été divisé par deux entre avant et après. Dit autrement, il s'agit de l'inverse du rapport calculé précédemment. L'équation obtenue ainsi, qui donne le gain en performance que l'on peut attendre en fonction du nombre de processeur et du pourcentage de code parallèle, est appelée la '''loi d'Amdhal'''.
: <math>G = \frac{1}{S + \frac{P}{N}} = \frac{1}{1 - P + \frac{P}{N}}</math>
Cette formule, la loi d'Amdhal proprement dit, nous donne donc une estimation du gain en temps d’exécution d'une application exécutée sur plusieurs processeurs. Mais que peut-on en déduire d'utile ? Peut-on trouver des moyens de gagner en performance efficacement grâce à cette loi ? Oui, et c'est ce qu'on va voir.
====La limite du code série====
On peut déduire de cette loi qu'il existe une limite maximale du gain obtenu par parallélisme de tâche. Quand on fait tendre le nombre de processeurs vers l'infini, le gain atteint un maximum qui vaut :
: <math>\text{Gain} = \frac {1}{1 - P} = \frac {1}{S}</math>
Quand N tend vers l'infini, le code parallélisé est exécuté en un temps qui tend vers 0 et seul reste le code série. Dit autrement, le temps d’exécution d'un programme ne peut pas descendre en dessous du temps d’exécution du code série, même avec une infinité de processeurs. La solution la plus simple est donc réduire S et augmenter P le plus possible. Seul problème : tous les programmes ne se laissent pas paralléliser aussi facilement. Certains programmes se parallélisent facilement parce que les calculs qu'ils ont à faire sont assez indépendants. Mais d'autres n'ont pas cette particularité et sont très très difficilement parallélisables, voire pas du tout.
====L'influence du nombre de CPU====
La loi d'Amdhal nous dit qu'augmenter le ombre de processeur permet de diminuer le terme <math>\frac{P}{N}</math> et d'augmenter le gain. Mais cette solution a une efficacité assez limitée : il faut que la part de code parallélisable soit suffisante pour que cela ait un impact suffisant. Imaginons un exemple simple : 20% du temps d’exécution de notre programme (quand il est exécuté sur un seul processeur) est passé à exécuter du code parallèle. Si on calcule le gain en fonction du nombre de processeurs, on obtient alors la tableau suivant.
{|class="wikitable"
|-
!Nombre de processeurs
!Gain maximal
|-
|2 || 11.11%
|-
|3 || 15.38%
|-
|4 || 17.64%
|-
|5 || 19.04%
|-
|6 || 20%
|-
|7 || 20.6%
|-
|8 || 21.21%
|-
|... || ...
|-
|16 || 23%
|-
|... || ...
|-
| <math>\infty</math> || 25%
|}
Les chiffres précédents nous disent qu'aller au-delà de 5 ou 6 processeurs ne sert pas vraiment à grand-chose. Doubler leur nombre revient souvent à augmenter les performances d'un misérable pourcent. Pour prendre un autre exemple, au-delà de 8 processeurs, un code passant 50% de son temps à exécuter du code parallèle ne sera pas vraiment exécuté plus vite. Pour résumer, le rendement du parallélisme diminue rapidement avec le nombre de processeurs. Passer de 2 à 4 processeurs permet d'obtenir des gains substantiels, alors que passer de 16 à 32 processeurs ne donne de gains sensibles que si P est extrêmement élevé.
[[File:AmdahlsLaw.svg|centre|vignette|upright=2.0|Loi d'Amdhal.]]
====Les limites pratiques====
Il faut préciser que la loi d'Amdhal est optimiste : elle a été démontrée en postulant que le code parallèle peut être réparti sur autant de processeurs qu'on veut et peut profiter d'un grand nombre de processeurs. Dans la réalité, rares sont les programmes de ce genre : certains programmes peuvent à la rigueur exploiter efficacement 2, 4 , voir 8 processeurs mais pas au-delà. Elle ne tient pas compte des nombreux problèmes techniques, aussi bien logiciels que matériels qui limitent les performances des programmes conçus pour exploiter plusieurs processeurs. La loi d'Amdhal donne une borne théorique maximale au gain apporté par la présence de plusieurs processeurs, mais le gain réel sera quasiment toujours inférieur au gain calculé par la loi d'Amdhal.
===Le parallélisme de données : la loi de Gustafson===
Le parallélisme de données est très utilisé pour des applications où les données sont indépendantes, et peuvent se traiter en parallèle. Un bon exemple est le calcul des graphismes d'un jeu vidéo. Chaque pixel étant colorié indépendamment des autres, on peut rendre chaque pixel en parallèle. C'est pour cela que les cartes vidéo contiennent un grand nombre de processeurs et sont des architectures SIMD capables d’exécuter un grand nombre de calculs en parallèle.
Le parallélisme de données n'a pas les limitations intrinsèques au parallélisme de tâches. En effet, celui-ci est avant tout limité certes par le code série, mais aussi par la quantité de données à traiter. Prenons le cas d'une carte graphique, par exemple : ajouter des processeurs ne permet pas de diminuer le temps de calcul, mais permet de traiter plus de données en même temps. Augmenter la résolution permet ainsi d'utiliser des processeurs qui auraient été en trop auparavant. Reste à quantifier de tels gains, si possible avec une loi similaire à celle d'Amdhal. Pour cela, nous allons partir du cas où on dispose d'un processeur qui doit traiter N données. Celui-ci met un temps <math>T_s</math> à éxecuter le code série et un temps <math>T_p</math> pour traiter une donnée. On a donc :
: <math>T = T_s + T_p \times N</math>
Avec N processeurs, le traitement des N données s'effectuera en un temps <math>T_p</math>, vu qu'il y a une donnée est prise en charge par un processeur, sans temps d'attente.
: <math>T_\text{parallele} = T_s + T_p \times N</math>
Le gain est donc :
: <math>\frac{T_\text{parallele}}{T} = \frac{T_s + T_p \times N}{T} = \frac{T_s}{T} + \frac{T_p}{T} \times N</math>
On peut utiliser les définitions <math>S = \frac{T_s}{T}</math> et <math>P = \frac{T_p}{T}</math> pour reformuler l'équation précédente :
: <math>G = S + P \times N = 1 - P + P \times N</math>
On voit que le gain est proportionnel au nombre de données à traiter. Plus on a de données à traiter, plus on gagnera à utiliser un grand nombre de processeurs. Il n'y a pas de limites similaires à celles obtenues à la loi d'Amdhal, du moins, tant qu'on dispose de suffisamment de données à traiter en parallèle.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les microarchitectures pour le x86 : basse consommation
| prevText=Les microarchitectures pour le x86 : basse consommation
| next=Architectures multiprocesseurs et multicœurs
| nextText=Architectures multiprocesseurs et multicœurs
}}
</noinclude>
s2b1h7crgu79ull4khxhubdu52798gw
Fonctionnement d'un ordinateur/Le chemin de données
0
69025
774728
774333
2026-10-10T19:45:25Z
Mewtow
31375
/* L'unité de calcul d'adresse */
774728
wikitext
text/x-wiki
Comme vu précédemment, le '''chemin de donnée''' est l'ensemble des composants dans lesquels circulent les données dans le processeur. Il comprend l'unité de calcul, les registres, l'unité de communication avec la mémoire, et le ou les interconnexions qui permettent à tout ce petit monde de communiquer. Dans ce chapitre, nous allons voir ces composants en détail.
==Les unités de calcul==
Le processeur contient des circuits capables de faire des calculs arithmétiques, des opérations logiques, et des comparaisons, qui sont regroupés dans une unité de calcul appelée '''unité arithmétique et logique'''. Certains préfèrent l’appellation anglaise ''arithmetic and logic unit'', ou ALU. Par défaut, ce terme est réservé aux unités de calcul qui manipulent des nombres entiers. Les unités de calcul spécialisées pour les calculs flottants sont désignées par le terme "unité de calcul flottant", ou encore FPU (''Floating Point Unit'').
L'interface d'une unité de calcul est assez simple : on a des entrées pour les opérandes et une sortie pour le résultat du calcul. De plus, les instructions de comparaisons ou de calcul peuvent mettre à jour le registre d'état, qui est relié à une autre sortie de l’unité de calcul. Une autre entrée, l''''entrée de sélection de l'instruction''', spécifie l'opération à effectuer. Elle sert à configurer l'unité de calcul pour faire une addition et pas une multiplication, par exemple. Sur cette entrée, on envoie un numéro qui précise l'opération à effectuer. La correspondance entre ce numéro et l'opération à exécuter dépend de l'unité de calcul. Sur les processeurs où l'encodage des instructions est "simple", une partie de l'opcode de l'instruction est envoyé sur cette entrée.
[[File:Unité de calcul usuelle.png|centre|vignette|upright=2|Unité de calcul usuelle.]]
Il faut signaler que les processeurs modernes possèdent plusieurs unités de calcul, toutes reliées aux registres. Cela permet d’exécuter plusieurs calculs en même temps dans des unités de calcul différentes, afin d'augmenter les performances du processeur. Diverses technologies, abordées dans la suite du cours permettent de profiter au mieux de ces unités de calcul : pipeline, exécution dans le désordre, exécution superscalaire, jeux d'instructions VLIW, etc. Mais laissons cela de côté pour le moment.
===L'ALU entière : additions, soustractions, opérations bit à bit===
Un processeur contient plusieurs ALUs spécialisées. La principale, présente sur tous les processeurs, est l''''ALU entière'''. Elle s'occupe uniquement des opérations sur des nombres entiers, les nombres flottants sont gérés par une ALU à part. Elle gère des opérations simples : additions, soustractions, opérations bit à bit, parfois des décalages/rotations. Par contre, elle ne gère pas la multiplication et la division, qui sont prises en charge par un circuit multiplieur/diviseur à part.
L'ALU entière a déjà été vue dans un chapitre antérieur, nommé "Les unités arithmétiques et logiques entières (simples)", qui expliquait comment en concevoir une. Nous avions vu qu'une ALU entière est une sorte de circuit additionneur-soustracteur amélioré, ce qui explique qu'elle gère des opérations entières simples, mais pas la multiplication ni la division. Nous ne reviendrons pas dessus. Cependant, il y a des choses à dire sur leur intégration au processeur.
Une ALU entière gère souvent une opération particulière, qui ne fait rien et recopie simplement une de ses opérandes sur sa sortie. L'opération en question est appelée l''''opération ''Pass through''''', encore appelée opération NOP. Elle est implémentée en utilisant un simple multiplexeur, placé en sortie de l'ALU. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, d'économiser des multiplexeurs. Mais nous verrons cela sous peu.
[[File:ALU avec opération NOP.png|centre|vignette|upright=2|ALU avec opération NOP.]]
Avant l'invention du microprocesseur, le processeur n'était pas un circuit intégré unique. L'ALU, le séquenceur et les registres étaient dans des puces séparées. Les ALU étaient vendues séparément et manipulaient des opérandes de 4/8 bits, les ALU 4 bits étaient très fréquentes. Si on voulait créer une ALU pour des opérandes plus grandes, il fallait construire l'ALU en combinant plusieurs ALU 4/8 bits. Par exemple, l'ALU des processeurs AMD Am2900 est une ALU de 16 bits composée de plusieurs sous-ALU de 4 bits. Cette technique qui consiste à créer des unités de calcul à partir d'unités de calcul plus élémentaires s'appelle en jargon technique du '''bit slicing'''. Nous en avions parlé dans le chapitre sur les unités de calcul, aussi nous n'en reparlerons pas plus ici.
L'ALU manipule des opérandes codées sur un certain nombre de bits. Par exemple, une ALU peut manipuler des entiers codés sur 8 bits, sur 16 bits, etc. En général, la taille des opérandes de l'ALU est la même que la taille des registres. Un processeur 32 bits, avec des registres de 32 bit, a une ALU de 32 bits. C'est intuitif, et cela rend l'implémentation du processeur bien plus facile. Mais il y a quelques exceptions, où l'ALU manipule des opérandes plus petits que la taille des registres. Par exemple, de nombreux processeurs 16 bits, avec des registres de 16 bits, utilisent une ALU de 8 bits. Un autre exemple assez connu est celui du Motorola 68000, qui était un processeur 32 bits, mais dont l'ALU faisait juste 16 bits. Son successeur, le 68020, avait lui une ALU de 32 bits.
Sur de tels processeurs, les calculs sont fait en plusieurs passes. Par exemple, avec une ALU 8 bit, les opérations sur des opérandes 8 bits se font en un cycle d'horloge, celles sur 16 bits se font en deux cycles, celles en 32 en quatre, etc. Si un programme manipule assez peu d'opérandes 16/32/64 bits, la perte de performance est assez faible. Diverses techniques visent à améliorer les performances, mais elles ne font pas de miracles. Par exemple, vu que l'ALU est plus courte, il est possible de la faire fonctionner à plus haute fréquence, pour réduire la perte de performance.
Pour comprendre comme est implémenté ce système de passes, prenons l'exemple du processeur 8 bit Z80. Ses registres entiers étaient des registres de 8 bits, alors que l'ALU était de 4 bits. Les calculs étaient faits en deux phases : une qui traite les 4 bits de poids faible, une autre qui traite les 4 bits de poids fort. Pour cela, les opérandes étaient placées dans des registres de 4 bits en entrée de l'ALU, plusieurs multiplexeurs sélectionnaient les 4 bits adéquats, le résultat était mémorisé dans un registre de résultat de 8 bits, un démultiplexeur plaçait les 4 bits du résultat au bon endroit dans ce registre. L'unité de contrôle s'occupait de la commande des multiplexeurs/démultiplexeurs. Les autres processeurs 8 ou 16 bits utilisent des circuits similaires pour faire leurs calculs en plusieurs fois.
[[File:ALU du Z80.png|centre|vignette|upright=2|ALU du Z80]]
Un exemple extrême est celui des des '''processeurs sériels''' (sous-entendu ''bit-sériels''), qui utilisent une '''ALU sérielle''', qui fait leurs calculs bit par bit, un bit à la fois. S'il a existé des processeurs de 1 bit, comme le Motorola MC14500B, la majeure partie des processeurs sériels étaient des processeurs 4, 8 ou 16 bits. L'avantage de ces ALU est qu'elles utilisent peu de transistors, au détriment des performances par rapport aux processeurs non-sériels. Mais un autre avantage est qu'elles peuvent gérer des opérandes de grande taille, avec plus d'une trentaine de bits, sans trop de problèmes.
===Les circuits multiplieurs et diviseurs===
Les processeurs modernes ont une ALU pour les opérations simples (additions, décalages, opérations logiques), couplée à une ALU pour les multiplications, un circuit multiplieur séparé. Précisons qu'il ne sert pas à grand chose de fusionner le circuit multiplieur avec l'ALU, mieux vaut les garder séparés par simplicité. Les processeurs haute performance disposent systématiquement d'un circuit multiplieur et gèrent la multiplication dans leur jeu d'instruction.
Les processeurs modernes intégrent deux multiplieurs, qui calculent des portions différentes du résultat. Pour rappel, une multiplication 64 bits a un résultat codé sur 128 bits. Et idem avec les multiplications 32 bits qui donnent des résultats codés sur 64, etc. Une multiplication a un résultat codés sur un nombre de bits deux dois plus larges que ses opérandes. De nombreux processeurs implémentent deux opérations de multiplications : une qui calcule les bits de poids faibles du résultat, l'autre pour les bits de poids fort. Le résultat est donc coupé en deux, mais on peut l'obtenir en deux opérations distinctes, pour mémoriser le résultat dans deux registres.
Et cela demande l'implication de deux multiplieurs. Un multiplieur qui calcule les bits de poids faible du résultat, un autre pour ceux de poids fort. Par exemple, un processeur 32 bits aura deux multiplieurs 32 bits : un qui calcule les 32 bits de poids faible du résultat, l'autre les 32 bits de poids fort. Il en est de même avec les architectures 64 bits, qui ont deux multiplieurs 64 bits, pour calculer un résultat de 128 bits.
Le cas de la division est plus compliqué. La présence d'un circuit multiplieur est commune, mais les circuits diviseurs sont eux très rares. Leur cout en circuit est globalement le même que pour un circuit multiplieur, mais le gain en performance est plus faible. Le gain en performance pour la multiplication est modéré car il s'agit d'une opération très fréquente, alors qu'il est très faible pour la division car celle-ci est beaucoup moins fréquente.
Pour réduire le cout en circuits, il arrive que l'ALU pour les multiplications gère à la fois la multiplication et la division. Les circuits multiplieurs et diviseurs sont en effet très similaires et partagent beaucoup de points communs. Généralement, la fusion se fait pour les multiplieurs/diviseurs itératifs.
===Le ''barrel shifter''===
On vient d'expliquer que la présence de plusieurs ALU spécialisée est très utile pour implémenter des opérations compliquées à insérer dans une unité de calcul normale, comme la multiplication et la division. Mais les décalages sont aussi dans ce cas, de même que les rotations. Nous avions vu il y a quelques chapitres qu'ils sont réalisés par un circuit spécialisé, appelé un ''barrel shifter'', qu'il est difficile de fusionner avec une ALU normale. Aussi, beaucoup de processeurs incorporent un ''barrel shifter'' séparé de l'ALU.
Les processeurs ARM utilise un ''barrel shifter'', mais d'une manière un peu spéciale. On a vu il y a quelques chapitres que si on fait une opération logique, une addition, une soustraction ou une comparaison, la seconde opérande peut être décalée automatiquement. L'instruction incorpore le type de de décalage à faire et par combien de rangs il faut décaler directement à côté de l'opcode. Cela simplifie grandement les calculs d'adresse, qui se font en une seule instruction, contre deux ou trois sur d'autres architectures. Et pour cela, l'ALU proprement dite est précédée par un ''barrel shifter''. Notons que les instructions MOV font aussi partie des instructions où la seconde opérande (le registre source) peut être décalé : cela signifie que les MOV passent par l'ALU, qui effectue alors un NOP, une opération logique OUI.
===Les unités de calcul spécialisées===
Un processeur peut disposer d’unités de calcul séparées de l'unité de calcul principale, spécialisées dans les décalages, les divisions, etc. Et certaines d'entre elles sont spécialisées dans des opérations spécifiques, qui ne sont techniquement pas des opérations entières, sur des nombres entiers.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Depuis les années 90-2000, presque tous les processeurs utilisent une unité de calcul spécialisée pour les nombres flottants : la '''Floating-Point Unit''', aussi appelée FPU. En général, elle regroupe un additionneur-soustracteur flottant et un multiplieur flottant. Parfois, elle incorpore un diviseur flottant, tout dépend du processeur. Précisons que sur certains processeurs, la FPU et l'ALU entière ne vont pas à la même fréquence, pour des raisons de performance et de consommation d'énergie !
La FPU intègre un circuit multiplieur entier, utilisé pour les multiplications flottantes, afin de multiplier les mantisses entre elles. Quelques processeurs utilisaient ce multiplieur pour faire les multiplications entières. En clair, au lieu d'avoir un multiplieur entier séparé du multiplieur flottant, les deux sont fusionnés en un seul circuit. Il s'agit d'une optimisation qui a été utilisée sur quelques processeurs 32 bits, qui supportaient les flottants 64 bits (double précision). Les processeurs Atom étaient dans ce cas, idem pour l'Athlon première génération. Les processeurs modernes n'utilisent pas cette optimisation pour des raisons qu'on ne peut pas expliquer ici (réduction des dépendances structurelles, émission multiple).
De nombreux processeurs modernes disposent d'une unité de calcul spécialisée dans le calcul des conditions, tests et branchements. C’est notamment le cas sur les processeurs sans registre d'état, qui disposent de registres à prédicats. En général, les registres à prédicats sont placés à part des autres registres, dans un banc de registre séparé. L'unité de calcul normale n'est pas reliée aux registres à prédicats, alors que l'unité de calcul pour les branchements/test/conditions l'est. Les registres à prédicats sont situés juste en sortie de cette unité de calcul.
Il existe des unités de calcul spécialisées pour les calculs d'adresse. Elles ne supportent guère plus que des incrémentations/décrémentations, des additions/soustractions, et des décalages simples. Les autres opérations n'ont pas de sens avec des adresses. L'usage d'ALU spécialisées pour les adresses est un avantage sur les processeurs où les adresses ont une taille différente des données, ce qui est fréquent sur les anciens processeurs 8 bits.
==Les registres du processeur==
Après avoir vu l'unité de calcul, il est temps de passer aux registres d'un processeur. L'organisation des registres est généralement assez compliquée, avec quelques registres séparés des autres comme le registre d'état ou le ''program counter''. Les registres d'un processeur peuvent se classer en deux camps : soit ce sont des registres isolés, soit ils sont regroupés en paquets appelés banc de registres.
Un '''banc de registres''' (''register file'') est une RAM, dont chaque byte est un registre. Il regroupe un paquet de registres différents dans un seul composant, dans une seule mémoire. Dans processeur moderne, on trouve un ou plusieurs bancs de registres. La répartition des registres, à savoir quels registres sont dans le banc de registre et quels sont ceux isolés, est très variable suivant les processeurs.
[[File:Register File Simple.svg|centre|vignette|upright=1|Banc de registres simplifié.]]
===L'adressage du banc de registres===
Le banc de registre est une mémoire comme une autre, avec une entrée d'adresse qui permet de sélectionner le registre voulu. Plutot que d'adresse, nous allons parler d''''identifiant de registre'''. Le séquenceur forge l'identifiant de registre en fonction des registres sélectionnés. Dans les chapitres précédents, nous avions vu qu'il existe plusieurs méthodes pour sélectionner un registre, qui portent les noms de modes d'adressage. Et bien les modes d'adressage jouent un grand rôle dans la forge de l'identifiant de registre.
Pour rappel, sur la quasi-totalité des processeurs actuels, les registres généraux sont identifiés par un nom de registre, terme trompeur vu que ce nom est en réalité un numéro. En clair, les processeurs numérotent les registres, le numéro/nom du registre permettant de l'identifier. Par exemple, si je veux faire une addition, je dois préciser les deux registres pour les opérandes, et éventuellement le registre pour le résultat : et bien ces registres seront identifiés par un numéro. Mais tous les registres ne sont pas numérotés et ceux qui ne le sont pas sont adressés implicitement. Par exemple, le pointeur de pile sera modifié par les instructions qui manipulent la pile, sans que cela aie besoin d'être précisé par un nom de registre dans l'instruction.
Dans le cas le plus simple, les registres nommés vont dans le banc de registres, les registres adressés implicitement sont en-dehors, dans des registres isolés. L'idéntifiant de registre est alors simplement le nom de registre, le numéro. Le séquenceur extrait ce nom de registre de l'insutrction, avant de l'envoyer sur l'entrée d'adresse du banc de registre.
[[File:Adressage du banc de registres généruax.png|centre|vignette|upright=2|Adressage du banc de registres généraux]]
Dans un cas plus complexe, des registres non-nommés sont placés dans le banc de registres. Par exemple, les pointeurs de pile sont souvent placés dans le banc de registre, même s'ils sont adressés implicitement. Même des registres aussi importants que le ''program counter'' peuvent se mettre dans le banc de registre ! Nous verrons le cas du ''program counter'' dans le chapitre suivant, qui porte sur l'unité de chargement. Dans ce cas, le séquenceur forge l'identifiant de registre de lui-même. Dans le cas des registres nommés, il ajoute quelques bits aux noms de registres. Pour les registres adressés implicitement, il forge l'identifiant à partir de rien.
[[File:Adressage du banc de registre - cas général.png|centre|vignette|upright=2|Adressage du banc de registre - cas général]]
Nous verrons plus bas que dans certains cas, le nom de registre ne suffit pas à adresser un registre dans un banc de registre. Dans ce cas, le séquenceur rajoute des bits, comme dans l'exemple précédent. Tout ce qu'il faut retenir est que l'identifiant de registre est forgé par le séquenceur, qui se base entre autres sur le nom de registre s'il est présent, sur l'instruction exécutée dans le cas d'un registre adressé implicitement.
===Les registres généraux===
Pour rappel, les registres généraux peuvent mémoriser des entiers, des adresses, ou toute autre donnée codée en binaire. Ils sont souvent séparés des registres flottants sur les architectures modernes. Les registres généraux sont rassemblés dans un banc de registre dédié, appelé le '''banc de registres généraux'''. Le banc de registres généraux est une mémoire multiport, avec au moins un port d'écriture et deux ports de lecture. La raison est que les instructions lisent deux opérandes dans les registres et enregistrent leur résultat dans des registres. Le tout se marie bien avec un banc de registre à deux de lecture (pour les opérandes) et un d'écriture (pour le résultat).
[[File:Banc de registre multiports.png|centre|vignette|upright=2|Banc de registre multiports.]]
L'interface exacte dépend de si l'architecture est une architecture 2 ou 3 adresses. Pour rappel, la différence entre les deux tient dans la manière dont on précise le registre où enregistrer le résultat d'une opération. Avec les architectures 2-adresses, on précise deux registres : le premier sert à la fois comme opérande et pour mémoriser le résultat, l'autre sert uniquement d'opérande. Un des registres est donc écrasé pour enregistrer le résultat. Sur les architecture 3-adresses, on précise trois registres : deux pour les opérandes, un pour le résultat.
Les architectures 2-adresses ont un banc de registre où on doit préciser deux "adresses", deux noms de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Medium.svg|centre|vignette|upright=1.5|Register File d'une architecture à 2-adresses]]
Les architectures 3-adresses doivent rajouter une troisième entrée pour préciser un troisième nom de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Large.svg|centre|vignette|upright=1.5|Register File d'une architecture à 3-adresses]]
Rien n'empêche d'utiliser plusieurs bancs de registres sur un processeur qui utilise des registres généraux. La raison est une question d'optimisation. Au-delà d'un certain nombre de registres, il devient difficile d'utiliser un seul gros banc de registres. Il faut alors scinder le banc de registres en plusieurs bancs de registres séparés. Le problème est qu'il faut prévoir de quoi échanger des données entre les bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue du langage machine. Sur d'autres processeurs, les transferts de données entre bancs de registres se font via une instruction spéciale, souvent appelée COPY.
===Les registres flottants : banc de registre séparé ou unifié===
Passons maintenant aux registres flottants. Intuitivement, on a des registres séparés pour les entiers et les flottants. Il est alors plus simple d'utiliser un banc de registres séparé pour les nombres flottants, à côté d'un banc de registre entiers. L'avantage est que les nombres flottants et entiers n'ont pas forcément la même taille, ce qui se marie bien avec deux bancs de registres, où la taille des registres est différente dans les deux bancs.
Mais d'autres processeurs utilisent un seul '''banc de registres unifié''', qui regroupe tous les registres de données, qu'ils soient entier ou flottants. Par exemple, c'est le cas des Pentium Pro, Pentium II, Pentium III, ou des Pentium M : ces processeurs ont des registres séparés pour les flottants et les entiers, mais ils sont regroupés dans un seul banc de registres. Avec cette organisation, un registre flottant et un registre entier peuvent avoir le même nom de registre en langage machine, mais l'adresse envoyée au banc de registres ne doit pas être la même : le séquenceur ajoute des bits au nom de registre pour former l'adresse finale.
[[File:Désambiguïsation de registres sur un banc de registres unifié.png|centre|vignette|upright=2|Désambiguïsation de registres sur un banc de registres unifié.]]
===Le registre d'état===
Le registre d'état fait souvent bande à part et n'est pas placé dans un banc de registres. En effet, le registre d'état est très lié à l'unité de calcul. Il reçoit des indicateurs/''flags'' provenant de la sortie de l'unité de calcul, et met ceux-ci à disposition du reste du processeur. Son entrée est connectée à l'unité de calcul, sa sortie est reliée au séquenceur et/ou au bus interne au processeur.
Le registre d'état est relié au séquenceur afin que celui-ci puisse gérer les instructions de branchement, qui ont parfois besoin de connaitre certains bits du registre d'état pour savoir si une condition a été remplie ou non. D'autres processeurs relient aussi le registre d'état au bus interne, ce qui permet de lire son contenu et de le copier dans un registre de données. Cela permet d'implémenter certaines instructions, notamment celles qui permettent de mémoriser le registre d'état dans un registre général.
[[File:Place du registre d'état dans le chemin de données.png|centre|vignette|upright=2|Place du registre d'état dans le chemin de données]]
L'ALU fournit une sortie différente pour chaque bit du registre d'état, la connexion du registre d'état est directe, comme indiqué dans le schéma suivant. Vous remarquerez que le bit de retenue est à la fois connecté à la sortie de l'ALU, mais aussi sur son entrée. Ainsi, le bit de retenue calculé par une opération peut être utilisé pour la suivante. Sans cela, diverses instructions comme les opérations ''add with carry'' ne seraient pas possibles.
[[File:AluStatusRegister.svg|centre|vignette|upright=2|Registre d'état et unit de calcul.]]
Il est techniquement possible de mettre le registre d'état dans le banc de registre, pour économiser un registre. La principale difficulté est que les instructions doivent faire deux écritures dans le banc de registre : une pour le registre de destination, une pour le registre d'état. Soit on utilise deux ports d'écriture, soit on fait les deux écritures l'une après l'autre. Dans les deux cas, le cout en performances et en transistors n'en vaut pas le cout. D'ailleurs, je ne connais aucun processeur qui utilise cette technique.
Il faut noter que le registre d'état n'existe pas forcément en tant que tel dans le processeur. Quelques processeurs, dont le 8086 d'Intel, utilisent des bascules dispersées dans le processeur au lieu d'un vrai registre d'état. Les bascules dispersées mémorisent chacune un bit du registre d'état et sont placées là où elles sont le plus utile. Les bascules utilisées pour les branchements sont proches du séquenceur, le bascules pour les bits de retenue sont placées proche de l'ALU, etc.
===Les registres à prédicats===
Les registres à prédicats remplacent le registre d'état sur certains processeurs. Pour rappel, les registres à prédicat sont des registres de 1 bit qui mémorisent les résultats des comparaisons et instructions de test. Ils sont nommés/numérotés, mais les numéros en question sont distincts de ceux utilisés pour les registres généraux.
Ils sont placés à part, dans un banc de registres séparé. Le banc de registres à prédicats a une entrée de 1 bit connectée à l'ALU et une sortie de un bit connectée au séquenceur. Le banc de registres à prédicats est parfois relié à une unité de calcul spécialisée dans les conditions/instructions de test. Pour rappel, certaines instructions permettent de faire un ET, un OU, un XOR entre deux registres à prédicats. Pour cela, l'unité de calcul dédiée aux conditions peut lire les registres à prédicats, pour combiner le contenu de plusieurs d'entre eux.
[[File:Banc de registre pour les registres à prédicats.png|centre|vignette|upright=2|Banc de registre pour les registres à prédicats]]
===Les registres dédiés aux interruptions===
Dans le chapitre sur les registres, nous avions vu que certains processeurs dupliquaient leurs registres architecturaux, pour accélérer les interruptions ou les appels de fonction. Dans le cas qui va nous intéresser, les interruptions avaient accès à leurs propres registres, séparés des registres architecturaux. Les processeurs de ce type ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. Si on peut utiliser deux bancs de registres séparés, il est aussi possible d'utiliser un banc de registre unifié pour les deux.
Sur certains processeurs, le banc de registre est dupliqué en plusieurs exemplaires. La technique est utilisée pour les interruptions. Certains processeurs ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. On peut utiliser deux bancs de registres séparés, un pour les interruptions, et un pour les programmes.
Sur d'autres processeurs, on utilise un banc de registre unifié pour les deux ensembles de registres. Les registres pour les interruptions sont dans les adresses hautes, les registres pour les programmes dans les adresses basses. Le choix entre les deux est réalisé par un bit qui indique si on est dans une interruption ou non, disponible dans une bascule du processeur. Appelons là la bascule I.
===Le fenêtrage de registres===
[[File:Fenetre de registres.png|vignette|upright=1|Fenêtre de registres.]]
Le '''fenêtrage de registres''' fait que chaque fonction a accès à son propre ensemble de registres, sa propre fenêtre de registres. Là encore, cette technique duplique chaque registre architectural en plusieurs exemplaires qui portent le même nom. Chaque ensemble de registres architecturaux forme une fenêtre de registre, qui contient autant de registres qu'il y a de registres architecturaux. Lorsqu'une fonction s’exécute, elle se réserve une fenêtre inutilisée, et peut utiliser les registres de la fenêtre comme bon lui semble : une fonction manipule le registre architectural de la fenêtre réservée, mais pas les registres avec le même nom dans les autres fenêtres.
Il peut s'implémenter soit avec un banc de registres unifié, soit avec un banc de registre par fenêtre de registres.
Il est possible d'utiliser des bancs de registres dupliqués pour le fenêtrage de registres. Chaque fenêtre de registre a son propre banc de registres. Le choix entre le banc de registre à utiliser est fait par un registre qui mémorise le numéro de la fenêtre en cours. Ce registre commande un multiplexeur qui permet de choisir le banc de registre adéquat.
[[File:Fenêtrage de registres au niveau du banc de registres.png|vignette|Fenêtrage de registres au niveau du banc de registres.]]
L'utilisation d'un banc de registres unifié permet d'implémenter facilement le fenêtrage de registres. Il suffit pour cela de regrouper tous les registres des différentes fenêtres dans un seul banc de registres. Il suffit de faire comme vu au-dessus : rajouter des bits au nom de registre pour faire la différence entre les fenêtres. Cela implique de se souvenir dans quelle fenêtre de registre on est actuellement, cette information étant mémorisée dans un registre qui stocke le numéro de la fenêtre courante. Pour changer de fenêtre, il suffit de modifier le contenu de ce registre lors d'un appel ou retour de fonction avec un petit circuit combinatoire. Bien sûr, il faut aussi prendre en compte le cas où ce registre déborde, ce qui demande d'ajouter des circuits pour gérer la situation.
[[File:Désambiguïsation des fenêtres de registres.png|centre|vignette|upright=2|Désambiguïsation des fenêtres de registres.]]
==L'unité mémoire==
L''''interface avec la mémoire''' est, comme son nom l'indique, des circuits qui servent d'intermédiaire entre le bus mémoire et le processeur. Elle est parfois appelée l'unité mémoire, l'unité d'accès mémoire, la ''load-store unit'', et j'en oublie. Nous utiliserons le terme d''''unité mémoire''', au même titre qu'on utilise le terme d'unité de calcul.
[[File:Unité de communication avec la mémoire, de type simple port.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type simple port.]]
Sur certains processeurs, elle gère les mémoires multiport.
[[File:Unité de communication avec la mémoire, de type multiport.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type multiport.]]
===Les registres d'interfaçage mémoire===
L'interface mémoire se résume le plus souvent à des '''registres d’interfaçage mémoire''', intercalés entre le bus mémoire et le chemin de données. Généralement, il y a au moins deux registres d’interfaçage mémoire : un registre relié au bus d'adresse, et autre relié au bus de données.
[[File:Registres d’interfaçage mémoire.png|centre|vignette|upright=2|Registres d’interfaçage mémoire.]]
Au lieu de lire ou écrire directement sur le bus, le processeur lit ou écrit dans ces registres, alors que l'unité mémoire s'occupe des échanges entre registres et bus mémoire. Lors d'une écriture, le processeur place l'adresse dans le registre d'interfaçage d'adresse, met la donnée à écrire dans le registre d'interfaçage de donnée, puis laisse l'unité d'accès mémoire faire son travail. Lors d'une lecture, il place l'adresse à lire sur le registre d'interfaçage d'adresse, il attend que la donnée soit lue, puis récupère la donnée dans le registre d'interfaçage de données.
L'avantage est que le processeur n'a pas à maintenir une donnée/adresse sur le bus durant tout un accès mémoire. Par exemple, prenons le cas où la mémoire met 15 cycles processeurs pour faire une lecture ou une écriture. Sans registres d'interfaçage mémoire, le processeur doit maintenir l'adresse durant 15 cycles, et aussi la donnée dans le cas d'une écriture. Avec ces registres, le processeur écrit dans les registres d'interfaçage mémoire au premier cycle, et passe les 14 cycles suivants à faire quelque chose d'autre. Par exemple, il faut faire un calcul en parallèle, envoyer des signaux de commande au banc de registre pour qu'il soit prêt une fois la donnée lue arrivée, etc. Cet avantage simplifie l'implémentation de certains modes d'adressage, comme on le verra à la fin du chapitre.
Les registres d’interfaçage peuvent être adressables, même si c'était extrêmement rare et que ca n'a été utilisé que sur de vieux ''mainframes''. Un exemple est celui du Burroughs B1700, qui expose deux registres d’interfaçage pour les données et un registre pour le bus d'adresse. Ils sont tous les trois adressables, ce qui fait que le processeur peut lire ou écrire dans ces registres avec une instruction MOV. Ils sont appelés : READ pour la donnée lue depuis la mémoire RAM, WRITE pour la donnée à écrire lors d'une écriture, MAR pour le bus d'adresse.
Une lecture/écriture était ainsi réalisée en plusieurs étapes, le séquenceur ne se souciait pas d'implémenter les instructions LOAD/STORE en plusieurs micro-opérations. Il fallait tout faire à la main, avec des instructions machines. Pour une lecture, il fallait placer l'adresse dans le registre MAR, puis exécuter une instruction LOAD, et récupérer la donnée lue dans le registre READ. Cela prenait une instruction MOV, suivie d'une instruction LOAD, elle-même suivie par une instruction MOV. avec un second MOV. Pour une écriture, il fallait placer la donnée à écrire dans le registre WRITE, puis placer l'adresse dans le registre MAR, et lancer une instruction WRITE. Le tout prenait donc deux MOV et une instruction WRITE.
===La gestion de l'alignement et du boutisme===
L'unité mémoire gère les accès mémoire non-alignés, à cheval sur deux mots mémoire (rappelez-vous le chapitre sur l'alignement mémoire). Dans le cas où les accès non-alignés sont interdits, elle lève une exception matérielle. Dans le cas où ils sont autorisés, elle les gère automatiquement, à savoir qu'elle charge deux mots mémoire et les combine entre eux pour donner le résultat final. Dans les deux cas, cela demande d'ajouter des circuits de détection des accès non-alignés, et éventuellement des circuits pour la double lecture/écriture.
Les circuits de détection des accès non-alignés sont très simples. Dans le cas où les adresses sont alignées sur une puissance de deux (cas le plus courant), il suffit de vérifier les bits de poids faible de l'adresse à lire. Prenons l'exemple d'un processeur avec des adresses codées sur 64 bits, avec des mots mémoire de 32 bits, alignés sur 32 bits (4 octets). Un mot mémoire contient 4 octets, les contraintes d'alignement font que les adresses autorisées sont des multiples de 4. En conséquence, les 2 bits de poids faible d'une adresse valide sont censés être à 0. En vérifiant la valeur de ces deux bits, on détecte facilement les accès non-alignés.
En clair, détecter les accès non-alignés demande de tester si les bits de poids faibles adéquats sont à 0. Il suffit donc d'un circuit de comparaison avec zéro; qui est une simple porte OU. Cette porte OU génère un bit qui indique si l'accès testé est aligné ou non : 1 si l'accès est non-aligné, 0 sinon. Le signal peut être transmis au séquenceur pour générer une exception matérielle, ou utilisé dans l'unité d'accès mémoire pour la double lecture/écriture.
La gestion automatique des accès non-alignés est plus complexe. Dans ce cas, l'unité mémoire charge deux mots mémoire et les combine entre eux pour donner le résultat final. Charger deux mots mémoires consécutifs est assez simple, si le registre d'interfaçage est un compteur. L'accès initial charge le premier mot mémoire, puis l'adresse stockée dans le registre d'interfaçage est incrémentée pour démarrer un second accès. Le circuit pour combiner deux mots mémoire contient des registres, des circuits de décalage, des multiplexeurs.
===L'unité de calcul d'adresse===
Les registres d'interfaçage sont presque toujours présents, mais le circuit que nous allons voir est complétement facultatif. Il s'agit d'une unité de calcul spécialisée dans les calculs d'adresse, appelée l''''''Address generation unit'''''. Nous utiliserons l'abréviation AGU dans ce qui suit.
L'AGU est utilisée pour les modes d'adressage indicés, qui additionnent une adresse de base et un indice qui a été décalé de quelques rangs. L'AGU se résume donc à un circuit décaleur et un additionneur. Notons que le décaleur n'est pas un ''barrel shifter'' complet. L'AGU n'implémente que les décalages par 1, 2, 3, 4, 5 rangs, rarement plus. La conséquence est que le cout en circuit de l'AGU est mineur sur les processeurs modernes. Et l'AGU est très rapide, elle peut faire son calcul en un seul cycle d'horloge.
Mais pourquoi ajouter une AGU, alors qu'on pourrait faire les calculs d'adresse dans l'ALU entière ? La réponse est que l'ALU entière ne permet pas de faire les calculs de manière optimale. Les calculs d'adresse fusionnent deux opérations : décalage et addition. Et les deux sont réalisées par des circuits séparés : le ''barrel shifter'' d'un côté, l'ALU entière proprement dite de l'autre. Faire un calcul d'adresse dans l'ALU entière demande donc deux micro-opérations, deux cycles d'horloge. A l'opposé, une AGU fait le décalage et l'addition en une seule fois, en une seule µop, en un seul cycle d'horloge.
Et c'est encore pire pour ce qui est du mode d'adressage "Base + Indice + Décalage", qui additionne une adresse de base, un indice décalé et un décalage (une constante immédiate). Il demande d'additionner trois opérandes, chose que l'ALU entière ne sait pas faire (sauf exceptions). Par contre, une AGU utilise un additionneur ''carry save'', capable d'additionner trois opérandes à la fois. Le résultat est que l'on peut faire les calculs d'adresse en un seul cycle d'horloge, pour un cout en hardware modéré.
[[File:Unité de calcul d'adresse.png|centre|vignette|upright=2|Unité de calcul d'adresse]]
Chose étrange, ce n'est pas ce qui était fait sur les premiers processeurs Intel. Sur ces CPUs, le calcul de l'adresse se faisait en deux cycles d'horloge. Lors du premier cycle d'horloge, l'ALU entière faisait le calcul d'adresse "Base + Indice". Lors du second cycle, le résultat de l'addition précédente est additionné avec le décalage, dans l'AGU. Pour cela, l'AGU était reliée à l'ALU entière et à l'unité de contrôle. Difficile de comprendre cette décision, l'implémentation ne profitant que partiellement des avantages mentionnés plus haut pour ce mode d'adressage.
Les processeurs ARM sont eux aussi à part. Ils utilisaient une ALU entière pour faire les calculs d'adresse, sauf qu'ils l'avaient optimisée pour justement pouvoir faire les calculs d'adresse en un cycle d'horloge. Pour cela, l'ALU entière était précédée par le ''barrel shifter''. Le ''barrel shifter'' décalait un opérande, puis l'ALU entière faisait l'opération demandée. L'ALU entière au sens large pouvait donc faire un décalage + une opération ALU en un cycle d'horloge.
Un autre avantage est des AGUs est au niveau du câblage du processeur. Utiliser une ALU entière pour faire les calculs d'adresse demande de câbler un chemin reliant la sortie de l'ALU entière à l'unité mémoire. Rien d'impossible, on verra même cela en détail à la fin du chapitre. Mais cette connexion est souvent difficile à câbler. En ajoutant une AGU, pas besoin de cette liaison. La liaison entre ALU et unité mémoire disparait, l'unité mémoire n'est reliée qu'aux registres.
Un autre détail est qu'utiliser une AGUs fait que les accès mémoire ne prennent qu'une seule µops. Et attention à ne pas confondre avec le calcul d'adresse. Je ne suis pas en train de répéter que les calculs d'adresse ne font qu'une seule µop avec une AGU. Non, c'est l'instruction mémoire elle-même, calcul d'adresse + accès mémoire, qui ne prennent qu'une seule µop. L'AGu est placée avant l'unité mémoire, l'adresse calculée par l'ALU est envoyée à l'unité mémoire directement, dans le même cycle d'horloge. C'est une instruction LOAD/STORE entière qui ne prend qu'une seule µop, µop qui fusionne calcule d'adresse et accès à la mémoire RAM ou au cache. Le séquenceur est simplifié, vu que les accès mémoire ne font plus qu'une seule micro-opération.
Quand elle intègre une AGU, l'unité mémoire doit avoir une interface adaptée. Elle doit recevoir les opérandes pour faire les calculs d'adresse. Elle a donc une entrée pour l'adresse de base, une autre pour l'indice, etc. Pour résumer, l'unité mémoire a une interface semblable à celle-ci :
[[File:Unité mémoire dans un pipeline.png|centre|vignette|upright=2.5|Unité mémoire avec calcul d'adresse intégré.]]
===Le rafraichissement mémoire optimisé et le contrôleur mémoire intégré===
Depuis les années 80, les processeurs sont souvent combinés avec une mémoire principale de type DRAM. De telles mémoires doivent être rafraichies régulièrement pour ne pas perdre de données. Le rafraichissement se fait généralement adresse par adresse, ou ligne par ligne (les lignes sont des super-bytes internes à la DRAM). Le rafraichissement est en théorie géré par le contrôleur mémoire installé sur la carte mère. Mais au tout début de l'informatique, du temps des processeurs 8 bits, le rafraichissement mémoire était géré directement par le processeur.
Divers processeurs implémentaient de quoi faciliter le rafraichissement par adresse. Par exemple, le processeur Zilog Z80 contenait un compteur de ligne, un registre qui contenait le numéro de la prochaine ligne à rafraichir. Il était incrémenté à chaque rafraichissement mémoire, automatiquement, par le processeur lui-même. Un ''timer'' interne permettait de savoir quand rafraichir la mémoire : quand ce ''timer'' atteignait 0, une commande de rafraichissement était envoyée à la mémoire, et le ''timer'' était ''reset''. Et tout cela était intégré à l'unité d'accès mémoire.
Depuis les années 2000, les processeurs modernes ont un contrôleur mémoire DRAM intégré directement dans le processeur. Ce qui fait qu'ils gèrent non seulement le rafraichissement, mais aussi d'autres fonctions bien pus complexes.
===L'interface de l'unité mémoire===
Vu de l'extérieur, l'unité mémoire ressemble à n'importe quel circuit électronique, avec des entrées et des sorties. L'unité mémoire est généralement multiport, avec un port d'entrée et un port de sortie. Le port d'entrée est là où on envoie l'adresse à lire/écrire, ainsi que la donnée à écrire pour les écritures. Si l'unité mémoire incorpore une AGU, on envoie aussi les indices et autres données sur le port d'entrée. Le port de sortie est utilisé pour récupérer le résultat des lectures. Une unité mémoire est donc reliée au chemin de données via deux entrées et une sortie : une entrée d'adresse, une entrée de données, et une sortie pour les données lues.
L'unité mémoire est connectée au reste du processeur grâce à un réseau d'interconnexion qu'on étudiera plus loin. Les connexions principales sont celles avec les registres : les adresses à lire/écrire sont souvent lues depuis les registres, les lectures copient une donnée dans un registre. Il peut y avoir une connexion avec l'unité de calcul pour les opérations ''load-up'', ou pour le calcul d'adresse, mais c'est secondaire.
[[File:Unité d'accès mémoire LOAD-STORE.png|centre|vignette|upright=2|Unité d'accès mémoire LOAD-STORE.]]
===Un exemple : l'unité mémoire du 8086 et ses dérivés===
Après avoir vu la théorie, voyons un exemple du monde réel. Nous allons étudier l'exemple du processeur Intel 8086 et de son dérivé, le 80186. Ces processeurs avaient une particularité : leur unité mémoire était fortement séparée du reste du processeur. Le processeur était séparé en une ''Bus Interface Unit'' et une ''Execution Unit''. L'''Execution Unit'' a un nom trompeur : c'est l'ensemble du processeur, unité mémoire exclue. La ''Bus Interface Unit'' est l'unité mémoire. Elle se charge à la fois du chargement des données et des instructions.
Cependant, nous n'allons pouvoir détailler cette unité dans le détail, car nous n'avons pas encore vu la mémoire virtuelle, ni le préchargement. Mais cela ne nous gênera pas, vous aurez simplement quelques détails passés sous silence pour ce qui est de l'unité de calcul. Toujours est-il que voici à quoi ressemble l'intérieur d'un 80186. Elle contient :
* des registres d'adresse utilisés pour ses fonctionnalités de segmentation ;
* une unité de calcul d'adresse utilisée pour les calculs d'adresse ;
* une interface avec le bus mémoire, qu'on détaillera plus bas ;
* une mémoire FIFO où les instructions sont chargées en avance, appelée la ''Prefetch Input Queue''.
[[File:80186 arch.png|centre|vignette|upright=2.5|Microarchitecture du 80186.]]
Nous détaillerons la ''Prefetch Input Queue'' dans le chapitre sur le préchargement. Les registres et l'unité de calcul seront détaillés dans le chapitre sur la mémoire virtuelle. Il est intéressant de regarder l'interface avec le bus mémoire. Cette interface contient les registres d’interfaçage, ainsi que des circuits annexes.
Pour comprendre ce qu'elle fait, il faut savoir que le processeur communique avec la mémoire avec une séquence très précise, qui dure exactement quatre cycles. Les quatre cycles sont appelés des ''T-states'' et ils sont numérotés de 1 à 4 : T1, T2, T3 et T4.
Pour une lecture, l'adresse est envoyée lors du premier cycle, elle est disponible sur le bus au troisième cycle. Les cycles 1 et 3 sont inutilisés, le processeur se déconnecte du bus lors de ces cycles.
{|class="wikitable"
|-
! T1 !! T2 !! T3 !! T4
|-
| Adresse || || Donnée lue ||
|}
Pour une lecture, l'adresse est envoyée lors du premier cycle, la donnée est envoyée sur le bus lors des trois cycles suivants.
{|class="wikitable"
! T1 !! T2 !! T3 !! T4
|-
| Adresse || colspan="3 | Donnée à écrire
|}
Les ''timings'' vus plus haut ne sont effectivement observés que si la mémoire est assez rapide. Mais le 8086 pouvait être combiné avec des mémoires RAM assez diverses, certaines plus lentes que d'autres. Si la mémoire est trop lente, elle peut prendre plusieurs cycles avant de renvoyer la donnée lue ou d'accepter la donnée à écrire. Pour gérer la situation, le processeur pouvait attendre que la mémoire réponde, avec des ''wait states''.
Pour cela, la mémoire prévenait quand la donnée lue était disponible, ou quand une écriture était terminée. Le processeur avait une entrée READY qui indiquait que la mémoire était disponible ou qu'elle avait terminé son travail. Lors d'une lecture, la mémoire mettait cette entrée READY à 1 quand la donnée lue était disponible sur le bus mémoire. Idem pour une écriture. L'implémentation dans l'unité mémoire est très simple : le processeur restait dans le cycle T3 en attendant que l'entrée READY soit à 1. Lors d'une écriture, cela maintenait à donnée à écrire sur le bus mémoire. Pour une lecture, cela permettait de laisser le registre d’interfaçage des données connecté au bus mémoire, en attendant la donnée lue.
Nous venons de voir que le processeur pouvait rester dans l'état T3 si le besoin s'en fait sentir. Mais cette astuce est utilisée dans d'autres situations. Par exemple, le processeur dispose d'une instruction HALT, qui le déconnecte totalement du bus. Le processeur reste déconnecté tant qu'il ne reçoit pas un RESET ou une interruption. Il s'agit d'une forme limitée de mise en veille, qui n'utilisait pas de méthodes pour économiser de l'énergie. L'instruction HALT était implémentée en restant en permanence dans l'état T2. Le processeur attendait d'avoir terminé toute instruction précédente, puis lançait une pseudo-lecture et restait en état T1 en attendant une interruption ou un RESET.
Le registre d’interfaçage pour les adresses est connecté sur le bus mémoire lors du premier cycle, celui pour les données l'est à des cycles différents suivant l'opération demandée. La connexion/déconnexion des registres d’interfaçage se fait en utilisant des circuits trois-états. Il y a une couche de circuits trois-états entre le registre d’interfaçage et le bus mémoire, qui permet de connecter/déconnecter les registres du bus mémoire, mais aussi d'imposer le sens de transfert suivant qu'on fasse une lecture ou une écriture.
[[File:Circuit d'interfacage mémoire du 8086.png|centre|vignette|upright=2|Circuit d’interfaçage mémoire du 8086.]]
Le circuit en question est un circuit séquentiel assez simple, une petite "machine à état" comme on le dit en termes techniques. Le circuit séquentiel commande les circuits trois-états. Une implémentation naïve serait d'utiliser un compteur pouvant compter de 0 à 3, avec des comparateurs. Il faut, en tout :
* Un comparateur qui détermine si le compteur est au cycle 1. Si c'est le cas, il connecte le registre d’interfaçage d'adresse sur le bus mémoire.
* Un autre comparateur détermine si une lecture est en cours et que l'on est dans le troisième cycle. Si c'est le cas, il configure les circuits trois-états du registre d’interfaçage de données, en mode lecture.
* Un autre comparateur détermine si une écriture est en cours et que l'on est dans les cycles adéquats. Si c'est le cas, il configure les circuits trois-états du registre d’interfaçage de données, en mode écriture.
Afin de simplifier fortement le circuit, le compteur utilisé utilise la représentation ''one hot''. Pour rappel, cela veut dire qu'il y a une bascule pour chaque valeur que peut prendre le compteur. Le compteur ''one hot'' est composé de quatre bascules. La première bascule est à 1 pour le cycle T1, la seconde est à 1 pour le cycle T2, la troisième est à 1 pour le cycle T3, la quatrième l'est pour le cycle T4. Ainsi, on peut se passer de certains comparateurs. Par exemple, le comparateur pour l'adresse disparait : la bascule commande directement les circuits trois-états. Les deux autres comparateurs se transforment des circuits à respectivement une ou quatre portes logiques.
Incrémenter le compteur se fait cependant sous certaines conditions. La machine à état contient des circuits qui décident quand incrémenter le compteur. Pour cela, il y a un circuit qui détermine quand on peut passer du cycle T1 au cycle T2, un autre pour le passage de T2 à T3, etc. Les circuits en question sont un paquet de portes logiques assez simple. Ils reçoivent des signaux de commande provenant du séquenceur, ainsi que des entrées comme l'entrée READY pour les ''wait states''.
[[File:Machine à état de l'unité mémoire du 8086 avec son compateur one hot.png|centre|vignette|upright=2|Machine à état de l'unité mémoire du 8086 avec son compateur one hot]]
Maintenant, parlons des problématiques liées au calcul d'adresse. Tous les accès mémoire demandent de faire un calcul d'adresse, en raison de l'usage de la fonctionnalité de segmentation. Et ce calcul d'adresse prend deux cycles. L'adresse est calculée, puis que l'accès mémoire est réalisé. En tout, cela fait 6 cycles : 2 pour le calcul d'adresse, puis les cycles T1, T2, T3 et T4.
Une optimisation importante est que deux accès mémoire peuvent se recouvrir. A savoir que pendant qu'un accès mémoire a lieu sur le bus mémoire, on calcule l'adresse de l'accès du suivant. Les deux ont lieu dans des circuits séparés, ce qui fait que c'est possible. Pour donner un exemple, on peut calculer l'adresse de la prochaine instruction, pendant que l'instruction mémoire en cours est exécutée. Le séquenceur du processeur est configuré pour démarrer le calcul d'adresse au bon moment. Il attend que le compteur soit dans l'état T3 et T4.
{|class="wikitable"
|-
! Unité de calcul
| class="f_vert" | Instruction 1 || class="f_vert" | Instruction 1 || || || class="f_rouge" | Instruction 2 || class="f_rouge" | Instruction 2 || || || ||
|-
! Bus mémoire
| || || class="f_vert" | Instruction 1 || class="f_vert" | Instruction 1 || class="f_vert" | Instruction 1 || class="f_vert" | Instruction 1 || class="f_rouge" | Instruction 2 || class="f_rouge" | Instruction 2 || class="f_rouge" | Instruction 2 || class="f_rouge" | Instruction 2
|}
: La machine à état est décrite en détail dans cet article de blog : [https://www.righto.com/2024/04/intel-8088-bus-state-machine.html Talking to memory: Inside the Intel 8088 processor's bus interface state machine ].
==Le chemin de données et son réseau d'interconnexions==
Nous venons de voir que le chemin de données contient une unité de calcul (parfois plusieurs), des registres isolés, un banc de registre, une unité mémoire. Le tout est chapeauté par une unité de contrôle qui commande le chemin de données, qui fera l'objet des prochains chapitres. Mais il faut maintenant relier registres, ALU et unité mémoire pour que l'ensemble fonctionne. Pour cela, diverses interconnexions internes au processeur se chargent de relier le tout.
Sur les anciens processeurs, les interconnexions sont assez simples et se résument à un ou deux '''bus internes au processeur''', reliés au bus mémoire. C'était la norme sur des architectures assez ancienne, qu'on n'a pas encore vu à ce point du cours, appelées les architectures à accumulateur et à pile. Mais ce n'est plus la solution utilisée actuellement. De nos jours, le réseaux d'interconnexion intra-processeur est un ensemble de connexions point à point entre ALU/registres/unité mémoire. Et paradoxalement, cela rend plus facile de comprendre ce réseau d'interconnexion.
===Introduction propédeutique : l'implémentation des modes d'adressage principaux===
L'organisation interne du processeur dépend fortement des modes d'adressage supportés. Pour simplifier les explications, nous allons séparer les modes d'adressage qui gèrent les pointeurs et les autres. Suivant que le processeur supporte les pointeurs ou non, l'organisation des bus interne est légèrement différente. La différence se voit sur les connexions avec le bus d'adresse et de données.
Tout processeur gère au minimum le '''mode d'adressage absolu''', où l'adresse est intégrée à l'instruction. Le séquenceur extrait l'adresse mémoire de l'instruction, et l'envoie sur le bus d'adresse. Pour cela, le séquenceur est relié au bus d'adresse, le chemin de donnée est relié au bus de données. Le chemin de donnée n'est pas connecté au bus d'adresse, il n'y a pas d'autres connexions.
[[File:Chemin de données sans support des pointeurs.png|centre|vignette|upright=2|Chemin de données sans support des pointeurs]]
Le '''support des pointeurs''' demande d'intégrer des modes d'adressage dédiés : l'adressage indirect à registre, l'adresse base + indice, et les autres. Les pointeurs sont stockés dans le banc de registre et sont modifiés par l'unité de calcul. Pour supporter les pointeurs, le chemin de données est connecté sur le bus d'adresse avec le séquenceur. Suivant le mode d'adressage, le bus d'adresse est relié soit au chemin de données, soit au séquenceur.
[[File:Chemin de données avec support des pointeurs.png|centre|vignette|upright=2|Chemin de données avec support des pointeurs]]
Pour terminer, il faut parler des instructions de '''copie mémoire vers mémoire''', qui copient une donnée d'une adresse mémoire vers une autre. Elles ne se passent pas vraiment dans le chemin de données, mais se passent purement au niveau des registres d’interfaçage. L'usage d'un registre d’interfaçage unique permet d'implémenter ces instructions très facilement. Elle se fait en deux étapes : on copie la donnée dans le registre d’interfaçage, on l'écrit en mémoire RAM. L'adresse envoyée sur le bus d'adresse n'est pas la même lors des deux étapes.
===Le banc de registre est multi-port, pour gérer nativement les opérations dyadiques===
Les architectures RISC et CISC incorporent un banc de registre, qui est connecté aux unités de calcul et au bus mémoire. Et ce banc de registre peut être mono-port ou multiport. S'il a existé d'anciennes architectures utilisant un banc de registre mono-port, elles sont actuellement obsolètes. Nous les aborderons dans un chapitre dédié aux architectures dites canoniques, mais nous pouvons les laisser de côté pour le moment. De nos jours, tous les processeurs utilisent un banc de registre multi-port.
[[File:Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres).png|centre|vignette|upright=2|Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres)]]
Le banc de registre multiport est optimisé pour les opérations dyadiques. Il dispose précisément de deux ports de lecture et d'un port d'écriture pour l'écriture. Un port de lecture par opérande et le port d'écriture pour enregistrer le résultat. En clair, le processeur peut lire deux opérandes et écrire un résultat en un seul cycle d'horloge. L'avantage est que les opérations simples ne nécessitent qu'une micro-opération, pas plus.
[[File:ALU data paths.svg|centre|vignette|upright=1.5|Processeur LOAD-STORE avec un banc de registre multiport, avec les trois ports mis en évidence.]]
===Une architecture LOAD-STORE basique, avec adressage absolu===
Voyons maintenant comment l'implémentation d'une architecture RISC très simple, qui ne supporte pas les adressages pour les pointeurs, juste les adressages inhérent (à registres) et absolu (par adresse mémoire). Les instructions LOAD et STORE utilisent l'adressage absolu, géré par le séquenceur, reste à gérer l'échange entre banc de registres et bus de données. Une lecture LOAD relie le bus de données au port d'écriture du banc de registres, alors que l'écriture relie le bus au port de lecture du banc de registre. Pour cela, il faut ajouter des interconnexions, comme illustré par le schéma ci-dessous.
[[File:Bus interne au processeur sur archi LOAD STORE avec banc de registres multiport.png|centre|vignette|upright=2|Organisation interne d'une architecture LOAD STORE avec banc de registres multiport. Nous n'avons pas représenté les signaux de commandes envoyés par le séquenceur au chemin de données.]]
Ajoutons ensuite les instructions de copie entre registres, souvent appelées instruction COPY ou MOV. Elles existent sur la plupart des architectures LOAD-STORE. Une première solution boucle l'entrée du banc de registres sur son entrée, ce qui ne sert que pour les copies de registres.
[[File:Chemin de données d'une architecture LOAD-STORE.png|centre|vignette|upright=2|Chemin de données d'une architecture LOAD-STORE]]
Mais il existe une seconde solution, qui ne demande pas de modifier le chemin de données. Il est possible de faire passer les copies de données entre registres par l'ALU. Lors de ces copies, l'ALU une opération ''Pass through'', à savoir qu'elle recopie une des opérandes sur sa sortie. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, dans le sens où cela permet d'économiser des multiplexeurs. Mais nous verrons cela sous peu. D'ailleurs, dans la suite du chapitre, nous allons partir du principe que les copies entre registres passent par l'ALU, afin de simplifier les schémas.
===L'ajout des modes d'adressage indirects à registre pour les pointeurs===
Passons maintenant à l'implémentation des modes d'adressages pour les pointeurs. Avec eux, l'adresse mémoire à lire/écrire n'est pas intégrée dans une instruction, mais est soit dans un registre, soit calculée par l'ALU.
Le premier mode d'adressage de ce type est le mode d'adressage indirect à registre, où l'adresse à lire/écrire est dans un registre. L'implémenter demande donc de connecter la sortie du banc de registres au bus d'adresse. Il suffit d'ajouter un MUX en sortie d'un port de lecture.
[[File:Chemin de données à trois bus.png|centre|vignette|upright=2|Chemin de données à trois bus.]]
Le mode d'adressage base + indice est un mode d'adressage où l'adresse à lire/écrire est calculée à partir d'une adresse et d'un indice, tous deux présents dans un registre. Le calcul de l'adresse peut être fait soit dans l'ALU entière, soit dans une AGU dédiée.
* Si l'unité mémoire intègre une AGU, il suffit de relier l'unité mémoire aux deux ports de lecture du banc de registre. Le schéma du dessous montre ce qu'il en est avec un calcul d'adresse fait dans l'ALU entière.
* S'il est réalisé dans l'ALU entière, on doit connecter la sortie de l'unité de calcul au bus d'adresse. L'instruction mémoire se fait alors en deux étapes : un calcul d'adresse, l'accès mémoire. Il est possible d'ajouter un registre sur cette connexion, afin de simplifier l'implémentation, le registre étant alors peu ou prou le registre d’interfaçage d'adresse. L'unité mémoire lit l'adresse à lire dans le registre intercalé entre l'ALU entière et l'unité mémoire.
[[File:Bus avec adressage base+index.png|centre|vignette|upright=2|Bus avec adressage base+index]]
Le chemin de données précédent gère aussi le mode d'adressage indirect avec pré-décrément. Pour rappel, ce mode d'adressage est une variante du mode d'adressage indirect, qui utilise une pointeur/adresse stocké dans un registre. La différence est que ce pointeur est décrémenté avant d'être envoyé sur le bus d'adresse. L'implémentation matérielle est la même que pour le mode Base + Indice : l'adresse est lue depuis les registres, décrémentée dans l'ALU, et envoyée sur le bus d'adresse.
Le schéma précédent montre que le bus d'adresse est connecté à un MUX avant l'ALU et un autre MUX après. Mais il est possible de se passer du premier MUX, utilisé pour le mode d'adressage indirect à registre. La condition est que l'ALU supporte l'opération ''pass through'', un NOP, qui recopie une opérande sur sa sortie. L'ALU fera une opération NOP pour le mode d'adressage indirect à registre, un calcul d'adresse pour le mode d'adressage base + indice. Par contre, faire ainsi rendra l'adressage indirect légèrement plus lent, vu que le temps de passage dans l'ALU sera compté.
[[File:Bus avec adressage indirect.png|centre|vignette|upright=2|Bus avec adressages pour les pointeurs, simplifié.]]
Dans ce qui va suivre, nous allons partir du principe que le processeur est implémenté en suivant le schéma précédent, afin d'avoir des schéma plus lisibles.
===L'adressage immédiat et les modes d'adressages exotiques===
Passons maintenant au mode d’adressage immédiat, qui permet de préciser une constante dans une instruction directement. La constante est extraite de l'instruction par le séquenceur, puis insérée au bon endroit dans le chemin de données. Pour les opérations arithmétiques/logiques/branchements, il faut insérer la constante extraite sur l'entrée de l'ALU. Sur certains processeurs, la constante peut être négative et doit alors subir une extension de signe dans un circuit spécialisé.
[[File:Chemin de données - Adressage immédiat avec extension de signe.png|centre|vignette|upright=2|Chemin de données - Adressage immédiat avec extension de signe.]]
L'implémentation précédente gère aussi les modes d'adressage base + décalage et absolu indexé. Pour rappel, le premier ajoute une constante à une adresse prise dans les registres, le second prend une adresse constante et lui ajoute un indice pris dans les registres. Dans les deux cas, on lit un registre, extrait une constante/adresse de l’instruction, additionne les deux dans l'ALU, avant d'envoyer le résultat sur le bus d'adresse. La seule difficulté est de désactiver l'extension de signe pour les adresses.
Le mode d'adressage absolu peut être traité de la même manière, si l'ALU est capable de faire des NOPs. L'adresse est insérée au même endroit que pour le mode d'adressage immédiat, parcours l'unité de calcul inchangée parce que NOP, et termine sur le bus d'adresse.
[[File:Chemin de données avec une ALU capable de faire des NOP.png|centre|vignette|upright=2|Chemin de données avec adressage immédiat étendu pour gérer des adresses.]]
Passons maintenant au cas particulier d'une instruction MOV qui copie une constante dans un registre. Il n'y a rien à faire si l'unité de calcul est capable d'effectuer une opération NOP/''pass through''. Pour charger une constante dans un registre, l'ALU est configurée pour faire un NOP, la constante traverse l'ALU et se retrouve dans les registres. Si l'ALU ne gère pas les NOP, la constante doit être envoyée sur l'entrée d'écriture du banc de registres, à travers un MUX dédié.
[[File:Implémentation de l'adressage immédiat dans le chemin de données.png|centre|vignette|upright=2|Implémentation de l'adressage immédiat dans le chemin de données]]
===Les architectures CISC : les opérations ''load-op''===
Tout ce qu'on a vu précédemment porte sur les processeurs de type LOAD-STORE, souvent confondus avec les processeurs de type RISC, où les accès mémoire sont séparés des instructions utilisant l'ALU. Il est maintenant temps de voir les processeurs CISC, qui gèrent des instructions ''load-op'', qui peuvent lire une opérande depuis la mémoire.
L'implémentation des opérations ''load-op'' relie le bus de donnée directement sur une entrée de l'unité de calcul, en utilisant encore une fois un multiplexeur. L'implémentation parait simple, mais c'est parce que toute la complexité est déportée dans le séquenceur. C'est lui qui se charge de détecter quand la lecture de l'opérande est terminée, quand l'opérande est disponible.
Les instructions ''load-op'' s'exécutent en plusieurs étapes, en plusieurs micro-opérations. Il y a typiquement une étape pour l'opérande à lire en mémoire et une étape de calcul. L'usage d'un registre d’interfaçage permet d'implémenter les instructions ''load-op'' très facilement. Une opération ''load-op'' charge l'opérande en mémoire dans un registre d’interfaçage, puis relier ce registre d’interfaçage sur une des entrées de l'ALU. Un simple multiplexeur suffit pour implémenter le tout, en plus des modifications adéquates du séquenceur.
[[File:Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire.png|centre|vignette|upright=2|Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire]]
Supporter les instructions multi-accès (qui font plusieurs accès mémoire) ne modifie pas fondamentalement le réseau d'interconnexion, ni le chemin de données La raison est que supporter les instructions multi-accès se fait au niveau du séquenceur. En réalité, les accès mémoire se font en série, l'un après l'autre, sous la commande du séquenceur qui émet plusieurs micro-opérations mémoire consécutives. Les données lues sont placées dans des registres d’interactivement mémoire, ce qui demande d'ajouter des registres d’interfaçage mémoire en plus.
==Annexe : le cas particulier du pointeur de pile==
Le pointeur de pile est un registre un peu particulier. Il peut être placé dans le chemin de données ou dans le séquenceur, voire dans l'unité de chargement, tout dépend du processeur. Tout dépend de si le pointeur de pile gère une pile d'adresses de retour ou une pile d'appel.
===Le pointeur de pile non-adressable explicitement===
Avec une pile d'adresse de retour, le pointeur de pile n'est pas adressable explicitement, il est juste adressé implicitement par des instructions d'appel de fonction CALL et des instructions de retour de fonction RET. Le pointeur de pile est alors juste incrémenté ou décrémenté par un pas constant, il ne subit pas d'autres opérations, son adressage est implicite. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Il n'y a pas besoin de le relier au chemin de données, vu qu'il n'échange pas de données avec les autres registres. Il y a alors plusieurs solutions, mais la plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Quelques processeurs simples disposent d'une pile d'appel très limitée, où le pointeur de pile n'est pas adressable explicitement. Il est adressé implicitement par les instruction CALL, RET, mais aussi PUSH et POP, mais aucune autre instruction ne permet cela. Là encore, le pointeur de pile ne communique pas avec les autres registres. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Là encore, le plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Dans les deux cas, le pointeur de pile est placé dans l'unité de contrôle, le séquenceur, et est associé à un incrémenteur dédié. Il se trouve que cet incrémenteur est souvent partagé avec le ''program counter''. En effet, les deux sont des adresses mémoire, qui sont incrémentées et décrémentées par pas constants, ne subissent pas d'autres opérations (si ce n'est des branchements, mais passons). Les ressemblances sont suffisantes pour fusionner les deux circuits. Ils peuvent donc avoir un '''incrémenteur partagé'''.
L'incrémenteur en question est donc partagé entre pointeur de pile, ''program counter'' et quelques autres registres similaires. Par exemple, le Z80 intégrait un registre pour le rafraichissement mémoire, qui était réalisé par le CPU à l'époque. Ce registre contenait la prochaine adresse mémoire à rafraichir, et était incrémenté à chaque rafraichissement d'une adresse. Et il était lui aussi intégré au séquenceur et incrémenté par l'incrémenteur partagé.
[[File:Organisation interne d'une architecture à pile.png|centre|vignette|upright=2|Organisation interne d'une architecture à pile]]
===Le pointeur de pile adressable explicitement===
Maintenant, étudions le cas d'une pile d'appel, précisément d'une pile d'appel avec des cadres de pile de taille variable. Sous ces conditions, le pointeur de pile est un registre adressable, avec un nom/numéro de registre dédié. Tel est par exemple le cas des processeurs x86 avec le registre ESP (''Extended Stack Pointer''). Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des calculs d'adresse, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres.
Dans ce cas, la meilleure solution est de placer le pointeur de pile dans le banc de registre généraux, avec les autres registres entiers. En faisant cela, la manipulation du pointeur de pile est faite par l'unité de calcul entière, pas besoin d'utiliser un incrémenteur dédiée. Il a existé des processeurs qui mettaient le pointeur de pile dans le banc de registre, mais l'incrémentaient avec un incrémenteur dédié, mais nous les verrons dans le chapitre sur les architectures à accumulateur. La raison est que sur les processeurs concernés, les adresses ne faisaient pas la même taille que les données : c'était des processeurs 8 bits, qui géraient des adresses de 16 bits.
==Annexe : l'implémentation du système d'''aliasing'' des registres des CPU x86==
Il y a quelques chapitres, nous avions parlé du système d'''aliasing'' des registres des CPU x86. Pour rappel, il permet de donner plusieurs noms de registre pour un même registre. Plus précisément, pour un registre 64 bits, le registre complet aura un nom de registre, les 32 bits de poids faible auront leur nom de registre dédié, idem pour les 16 bits de poids faible, etc. Il est possible de faire des calculs sur ces moitiés/quarts/huitièmes de registres sans problème.
===L'''aliasing'' du 8086, pour les registres 16 bits===
[[File:Register 8086.PNG|vignette|Register 8086]]
L'implémentation de l'''aliasing'' est apparue sur les premiers CPU Intel 16 bits, notamment le 8086. En tout, ils avaient quatre registres généraux 16 bits : AX, BX, CX et DX. Ces quatre registres 16 bits étaient coupés en deux octets, chacun adressable. Par exemple, le registre AX était coupé en deux octets nommés AH et AL, chacun ayant son propre nom/numéro de registre. Les instructions d'addition/soustraction pouvaient manipuler le registre AL, ou le registre AH, ce qui modifiait les 8 bits de poids faible ou fort selon le registre choisit.
Le banc de registre ne gére que 4 registres de 16 bits, à savoir AX, BX, CX et DX. Lors d'une lecture d'un registre 8 bits, le registre 16 bit entier est lu depuis le banc de registre, mais les bits inutiles sont ignorés. Par contre, l'écriture peut se faire soit avec 16 bits d'un coup, soit pour seulement un octet. Le port d'écriture du banc de registre peut être configuré de manière à autoriser l'écriture soit sur les 16 bits du registre, soit seulement sur les 8 bits de poids faible, soit écrire dans les 8 bits de poids fort.
[[File:Port d'écriture du banc de registre du 8086.png|centre|vignette|upright=2.5|Port d'écriture du banc de registre du 8086]]
Une opération sur un registre 8 bits se passe comme suit. Premièrement, on lit le registre 16 bits complet depuis le banc de registre. Si l'on a sélectionné l'octet de poids faible, il ne se passe rien de particulier, l'opérande 16 bits est envoyée directement à l'ALU. Mais si on a sélectionné l'octet de poids fort, la valeur lue est décalée de 7 rangs pour atterrir dans les 8 octets de poids faible. Ensuite, l'unité de calcul fait un calcul avec cet opérande, un calcul 16 bits tout ce qu'il y a de plus classique. Troisièmement, le résultat est enregistré dans le banc de registre, en le configurant convenablement. La configuration précise s'il faut enregistrer le résultat dans un registre 16 bits, soit seulement dans l'octet de poids faible/fort.
Afin de simplifier le câblage, les 16 bits des registres AX/BX/CX/DX sont entrelacés d'une manière un peu particulière. Intuitivement, on s'attend à ce que les bits soient physiquement dans le même ordre que dans le registre : le bit 0 est placé à côté du bit 1, suivi par le bit 2, etc. Mais à la place, l'octet de poids fort et de poids faible sont mélangés. Deux bits consécutifs appartiennent à deux octets différents. Le tout est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! Registre 16 bits normal
| class="f_bleu" | 15
| class="f_bleu" | 14
| class="f_bleu" | 13
| class="f_bleu" | 12
| class="f_bleu" | 11
| class="f_bleu" | 10
| class="f_bleu" | 9
| class="f_bleu" | 8
| class="f_rouge" | 7
| class="f_rouge" | 6
| class="f_rouge" | 5
| class="f_rouge" | 4
| class="f_rouge" | 3
| class="f_rouge" | 2
| class="f_rouge" | 1
| class="f_rouge" | 0
|-
! Registre 16 bits du 8086
| class="f_bleu" | 15
| class="f_rouge" | 7
| class="f_bleu" | 14
| class="f_rouge" | 6
| class="f_bleu" | 13
| class="f_rouge" | 5
| class="f_bleu" | 12
| class="f_rouge" | 4
| class="f_bleu" | 11
| class="f_rouge" | 3
| class="f_bleu" | 10
| class="f_rouge" | 2
| class="f_bleu" | 9
| class="f_rouge" | 1
| class="f_bleu" | 8
| class="f_rouge" | 0
|}
En faisant cela, le décaleur en entrée de l'ALU est bien plus simple. Il y a 8 multiplexeurs, mais le câblage est bien plus simple. Par contre, en sortie de l'ALU, il faut remettre les bits du résultat dans l'ordre adéquat, celui du registre 8086. Pour cela, les interconnexions sur le port d'écriture sont conçues pour. Il faut juste mettre les fils de sortie de l'ALU sur la bonne entrée, par besoin de multiplexeurs.
===L'''aliasing'' sur les processeurs x86 32/64 bits===
Les processeurs x86 32 et 64 bits ont un système d'''aliasing'' qui complète le système précédent. Les processeurs 32 bits étendent les registres 16 bits existants à 32 bits. Pour ce faire, le registre 32 bit a un nouveau nom de registre, distincts du nom de registre utilisé pour l'ancien registre 16 bits. Il est possible d'adresser les 16 bits de poids faible de ce registre, avec le même nom de registre que celui utilisé pour le registre 16 sur les processeurs d'avant. Même chose avec les processeurs 64, avec l'ajout d'un nouveau nom de registre pour adresser un registre de 64 bit complet.
En soit, implémenter ce système n'est pas compliqué. Prenons le cas du registre RAX (64 bits), et de ses subdivisions nommées EAX (32 bits), AX (16 bits). À l'intérieur du banc de registre, il n'y a que le registre RAX. Le banc de registre ne comprend qu'un seul nom de registre : RAX. Les subdivisions EAX et AX n'existent qu'au niveau de l'écriture dans le banc de registre. L'écriture dans le banc de registre est configurable, de manière à ne modifier que les bits adéquats. Le résultat d'un calcul de l'ALU fait 64 bits, il est envoyé sur le port d'écriture. À ce niveau, soit les 64 bits sont écrits dans le registre, soit seulement les 32/16 bits de poids faible. Le système du 8086 est préservé pour les écritures dans les 16 bits de poids faible.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les composants d'un processeur
| prevText=Les composants d'un processeur
| next=L'unité de chargement et le program counter
| nextText=L'unité de chargement et le program counter
}}
</noinclude>
683j0mf246bnb7k3ziaw8ba40odpctf
Fonctionnement d'un ordinateur/Les architectures dataflow
0
69053
774774
755755
2026-10-11T01:28:34Z
Mewtow
31375
/* Les architectures dataflow hybrides */
774774
wikitext
text/x-wiki
Dans ce chapitre, nous allons nous intéresser aux '''architectures ''dataflow'''''. Leur spécificité est que les dépendances entre instructions sont encodées directement dans le code machine. Le processeur peut ainsi déterminer quelle instruction est prête à s'exécuter en fonction de la disponibilité des opérandes. En clair, l'exécution dans le désordre est réalisée par le compilateur, au niveau du jeu d'instruction.
==Le jeu d'instruction ''dataflow'' théorique, un modèle de calcul==
[[File:Dataflow graph.PNG|vignette|Exemple de graphe ''dataflow''.]]
Sur une architecture ''dataflow'', un programme est représenté par un graphe, à savoir un ensemble de points qu'on relie par des flèches. Les points sont appelés des nœuds, et ils représentent ici une instruction chacun. Les flèches encodent les dépendances entre instructions : à quelle opération envoyer tel résultat, quelle instruction fournit telle opérande, etc. Pour résumer, les nœuds correspondent aux instructions, alors que les flèches encodent les dépendances RAW entre instructions.
Avant de poursuivre, petit instant terminologie. Une instruction prend en entrée des opérandes et fournit un résultat. On dit aussi qu'elle consomme des opérandes et produit un résultat. Lorsqu'une instruction a pour opérande le résultat d'une autre, on dit que la première est l'instruction consommatrice, alors que celle qui produit l'opérande est l'instruction productrice. Une instruction productrice et consommatrices ont une dépendance de donnée de type RAW. Dans un graphe ''dataflow'', une instruction productrice émet une flèche vers la ou les instructions consommatrices, ces flèches encodent les dépendances entre instructions.
===Les architectures à réduction de graphe et l'encodage des programmes===
Les architectures ''dataflow'' sont conçues pour exécuter automatiquement un programme sous forme de graphe. Le stockage de ce graphe dépend de l'ordinateur utilisé, mais vous pouvez être certains qu'il est stocké quelque part (même si parfois de manière implicite) dans la mémoire de l'ordinateur et que le processeur l'utilise pour savoir quelles instructions exécuter et dans quel ordre. Niveau implémentation, les nœuds et flèches sont implémentés différemment. Implémenter les flèches demande d'encoder les dépendances entre instructions productrices et consommatrices.
Pour cela, les architectures ''dataflow'' se passent de ''program counter''. À la place, chaque instruction intègre l'adresse de l'instruction suivante. Pour le dire autrement, l'instruction productrice encode l'adresse de l'instruction consommatrice, juste à côté de l'opcode. Évidemment, c'est là le cas le plus simple, où il y a une seule instruction consommatrice. S'il y en a plusieurs, il faut encoder une liste des instructions consommatrices. En clair, chaque instruction encode la liste des instructions suivantes, les adresses mémoire de toute instruction qui a besoin de son résultat.
[[File:Encodage d'une instruction sur les architectures dataflow.png|centre|vignette|upright=3|Encodage d'une instruction sur les architectures dataflow]]
En plus de l'adresse de l'instruction consommatrice, l'instruction peut encoder d'autres informations utiles. L'une d'entre elle est si l'instruction consommatrice consomme une ou deux opérandes. Une instruction peut utiliser une opérande (par exemple, l'opération NOT) ou deux opérandes (addition, soustraction, autres), parfois plus. Mais sur les architectures ''dataflow'', on se limite aux instructions à une ou deux opérandes, pas plus. Le caractère mono- ou bi-opérande est encodé sur un bit. Il est utilisé pour certaines optimisations, comme on le verra plus bas.
===Les instructions d'un programme ''dataflow''===
Les instructions arithmétiques et logiques consomment leurs entrées et fournissent un résultat sans rien faire d'autre : elles sont sans aucun effet de bord. Elles n'ont pas le choix, vu que l'ordre des instructions n'est pas garantit. Si elles modifiaient l'état du processeur, cela altérerait l'exécution des instructions suivantes. Et vu que ces instructions suivantes ne seraient pas les mêmes d'une exécution du programme à l'autre, du fait de l'exécution dans le désordre, cela causerait des bugs très difficiles à corriger.
{|
|[[File:Singledataflownode.png|vignette|Représentation d'une instruction ''dataflow''.]]
|[[File:Dataflowexpr.png|vignette|upright=0.5|Extrait de code ''dataflow'' (graphe simple) avec plusieurs instructions arithmétiques.]]
|}
Outre les instructions normales, on trouve des instructions équivalentes aux instructions de tests et de branchements, appelées ''switch'' et ''merge''. Pour faire simple, les ''merges'' sont des instructions qui effectuent des comparaisons ou des tests et qui fournissent un résultat du style : la propriété testée est vraie (ou fausse). Les ''switch'' quant à eux, sont l'équivalent des instructions de branchements, vues plus haut.
{|
|[[File:Switchdataflownode.png|vignette|Switch.]]
|[[File:Mergedataflownode.png|vignette|Merge.]]
|}
Ces instructions permettent de créer des structures de contrôle usuelles.
{|
|[[File:Conditiondataflow.png|vignette|IF/ELSE en code ''dataflow''.]]
|[[File:Dataflowwhileloop.png|vignette|upright=1.5|Boucle WHILE en code ''dataflow''.]]
|}
===La gestion de la disponibilité des opérandes===
Sur une architecture ''dataflow'', une instruction s'exécute dès que ses opérandes sont disponibles, comme sur les architectures à exécution dans le désordre. Sauf que les architectures ''dataflow'' n'ont pas besoin de détecter les dépendances entre instructions, car elles leur sont données sur un plateau, tout est géré au niveau du jeu d'instruction. En théorie, les architectures ''dataflow'' peuvent en profiter pour charger et exécuter plusieurs instructions indépendantes en même temps. Pour cela, elles intègrent plusieurs unités de calcul, mais elles peuvent aussi inclure plusieurs processeurs ''dataflow'' reliés entre eux par un réseau local de communication.
[[File:Dataflowfiringnodes.png|centre|vignette|upright=2|Le schéma montre l'exécution d'une instruction sur un ordinateur ''dataflow''. Lorsque tous les opérandes d'une instruction consommatrices sont disponibles, l'instruction consomme les opérandes et fournit un résultat.]]
Lorsqu'une instruction génère son résultat, celui-ci est combiné avec la liste des instructions consommatrices. La combinaison est assez logique, vu qu'elle regroupe le résultat et les instructions qui vont le consommer. Le regroupement est appelé un '''jeton''', terminologie beaucoup utilisée dans la littérature sur les architectures ''dataflow''. Le terme a cependant été utilisé pour désigner beaucoup de concepts similaire dans cette littérature : il désigne tout regroupement entre une donnée/opérande avec d'autres informations supplémentaires.
Dans le cas des instructions SWITCH et MERGE, qui remplacent les branchements, la liste d'instruction consommatrice n'est pas la même selon que le branchement est pris ou non.
Le jeton inclus aussi d'autres informations, associées avec les adresses des instructions consommatrices. Notamment, il inclut le caractère mono- ou bi-opérande de chaque instruction consommatrice. La raison est assez simple. Une instruction mono-opérande peut s'exécuter automatiquement si son adresse est encodée dans un jeton, car le jeton contient la seule opérande demandée par l'instruction mono-opérande pour s'exécuter. Elle peut être exécutée immédiatement et le processeur peut s'en rendre compte très facilement, juste en testant un bit. Par contre, les instructions bi-opérande doivent avoir deux jetons, deux opérandes prêtes. Détecter si une instruction bi-opérande peut s'exécuter demande plus de travail et un circuit spécialisé.
Le principal problème avec ce système est lié aux boucles ou à des fonctionnalités similaires. En présence de boucles, il se peut qu'une instruction productrice se soit déjà exécutée plusieurs fois et ait fournit plusieurs résultats. L'instruction consommatrice doit alors utiliser ces résultats dans le bon ordre, du plus ancien au plus récent. La réponse à ce problème distingue les architectures ''dataflow'' statiques et dynamiques.
==Les architectures ''dataflow'' statiques==
Les '''architectures dataflow statiques''' sont les plus simples. Leur design est très inspiré du design original de Dennis, premier chercheur à avoir proposé un design d'architecture ''dataflow''. Sur ces architectures, une instruction productrice doit attendre que les instructions consommatrices aient consommés son résultat, avant de s'exécuter à nouveau.
En raison de cette contrainte, certaines fonctionnalités importantes des langages de programmation fonctionnels ne sont pas implémentables sur ces architectures. Les fonctionnalités en question permettent d’exécuter plusieurs instances d'une même instruction : boucles qui répètent une instruction, fonctions de première classe, fonctions réentrantes, récursivité, etc. Les limitations des architectures ''dataflow'' statiques sont un gros problème. Se passer de boucles ou de fonctions réentrantes n'est pas une option valable pour les programmeurs. Et cela explique que de telles architectures se comptent sur les doigts d'une main.
Elles ont été introduites par Dennis, dans un papier de recherche, qui a été implémenté en hardware par lui et son équipe. Le prototype était appelé la '''''MIT static machine dataflow''''', et était implémenté avec 8 processeurs interconnectés avec des routeurs, avec un ordinateur PDP-11 servant d'interface. Les 8 processeurs étaient des processeurs séquentiels normaux qui émulaient un processeur ''dataflow'' via du microcode, les unités de calcul étaient implémentées avec des ALU bit-slicées d'AMD. D'autres prototypes du même genre ont été inventés, comme celui du projet LAU "langage à assignation unique" de l'université de Toulouse.
Une implémentation hardware sans microcode est celle du '''µPD7281 de Texas Instrument'''. Son architecture est décrite dans ce document : [http://www.merlintec.com/download/nec7281.pdf]. Une autre machien du même genre a été inventée par la Hughes Aircraft
Company.
===Le codage statique des instructions===
Une instruction contient, outre son opcode, un espace pour stocker chaque opérande, ainsi que des bits de présence qui indiquent si l'opérande associé est disponible. Les opérandes sont donc stockés dans l'instruction et sont mises à jour par réécriture directe de l'instruction en mémoire. Une instruction contient donc un ou plusieurs opérandes, qui sont chacun associé à un bit de présence qui indique si l'opérande est disponible ou non. Tout cela ressemble aux entrées des stations de réservation sur les processeurs à exécution dans le désordre, à un détail près : tout est stocké en mémoire, dans la suite de bits qui code l'instruction. De plus, chaque instruction contient l'adresse de l'instruction suivante, celle à laquelle envoyer le résultat.
{|
|[[File:Dataflowsimplestructure.png|centre|vignette|upright=1.5|Illustration du codage d'une instruction sur une architecture dataflow statique. On voit bien que chaque instruction a un espace réservé pour chaque opérande, ainsi qu'un pointeur vers la ou les instructions consommatrices.]]
|[[File:Dataflowiterstructure.png|centre|vignette|upright=1.5|Même chose, mais avec des instructions Switch et Merge.Notez que la l'instruction consommatrice change selon le résultat du Switch (F pour Faux et V pour Vrai).]]
|}
Avec ces architectures, on doit empêcher une instruction productrice de fournir un résultat si un autre résultat est en attente d'être consommé. En effet, il ne faut pas que le nouveau résultat écrase celui en attente dans l'instruction consommatrice. Pour empêcher cela, chaque instruction contient un bit qui indique si elle peut calculer un nouveau résultat. Il est appelé le '''jeton d'autorisation'''. Quand une instruction consommatrice s'exécute, elle prévient ses instructions productrices, pour leur dire qu'elle est prête à accepter un nouvel opérande. Les instructions productrices s'exécutent alors, sauf si elles sont dans la même situation, fournissent leur résultat et l'enregistre dans l'instruction consommatrice.
[[File:Représentation d'une instruction en mémoire sur une architecture dataflow.jpg|centre|vignette|upright=2|Représentation d'une instruction en mémoire sur une architecture dataflow statique.]]
L'introduction du jeton d'autorisation demande d'ajouter de quoi le gérer. L'instruction productrice fournit un résultat qui est utilisé par N instructions consommatrices, donc N jetons d'autorisation nécessaires. Pour garder la trace des N jetons d'autorisation nécessaires, une instruction productrice contient un champ "autorisation". Il est initialisé à la valeur N, mais est décrémenté à chaque fois qu'un jeton d'autorisation est émis. Quand il atteint 0, l'instruction productrice peut s'exécuter, si ses opérandes sont disponibles.
===La microarchitecture d'une architecture ''dataflow'' statique===
Dans les grandes lignes, une architecture ''dataflow'' statique est composée de deux éléments principaux, une ''Enabling Unit'' et une unité de calcul, représentés sur le schéma que vous pouvez voir en dessous. Seul l'''Enabling Unit'' est reliée à la mémoire, l'unité de calcul ne l'est pas. Le séquenceur sert d'intermédiaire entre unité de calcul et mémoire RAM, c'est un peu comme une sorte de fusion entre un séquenceur normal et l'unité de communication avec la mémoire. Elle enregistre les résultats fournit par l'ALU en RAM, dans les champs opérande des instructions consommatrices. Lors de l'écriture d'un résultat, elle vérifie si l'instruction consommatrice a toutes opérandes, et l'exécute cas échéant.
[[File:Diagramme d'une architecture dataflow statique simple.jpg|centre|vignette|upright=2|Diagramme d'une architecture dataflow statique simple]]
Le défi est qu'un résultat est souvent envoyé à plusieurs instructions consommatrices, et qu'elles doivent toutes s'exécuter à se moment. Un moyen pour cela est d'en exécuter une et de mettre les autres en attente. Pour cela, le séquenceur peut intégrer une file d'instructions, qui met en attente les instructions consommatrices dont toutes les opérandes sont prêtes. L'''enabling unit'' vérifie le contenu de la file d'instruction dès qu'elle veut exécuter une nouvelle instruction.
[[File:Diagramme d'une architecture dataflow statique avec file d'instruction.jpg|centre|vignette|upright=2|Diagramme d'une architecture dataflow statique avec file d'instruction]]
La file d'attente des instruction contient soit une copie des instructions elles-mêmes, soit leurs adresses en mémoire RAM. La seconde solution est beaucoup plus économe en circuit, ce qui fait qu'elle est préférée à la première. Dans les deux cas, l'introduction de la file d'attente fait qu'il est préférable de scinder l'''Enabling Unit'' en deux. La première est l'unité d'appariement, ou ''Matching Unit'', qui s'occupe de l'enregistrement des opérandes en RAM et de la détection des instructions prêtes. L'autre unité s'occupe du décodage des instructions, et éventuellement de leur chargement si la file d'instruction mémorise des adresses mémoire.
[[File:Diagramme d'une architecture dataflow statique.jpg|centre|vignette|upright=2|Diagramme d'une architecture dataflow statique]]
==Les architectures ''dataflow'' dynamiques==
Les '''architectures ''dataflow'' dynamiques''' acceptent plusieurs instances d'une même opérande, produites par une même instruction productrice. Mais il faut garantir que les opérandes soient utilisés dans l'ordre, dans leur ordre de production. Pour cela, ces architectures ''dataflow'' dynamiques associent un ''tag'' à chaque opérande, un numéro qui indique leur ordre de production. La gestion des opérandes change du tout au tout. Sur les architectures ''dataflow'' statiques, jetons et opérandes étaient approximativement la même chose, si ce n'est que les opérandes étaient propagées avec l'adresse de l'instruction consommatrice. Mais sur les architectures ''dataflow'' dynamiques, les jetons contiennent en plus le ''tag''.
: Il est intéressant de faire une analogie entre les architectures ''dataflow'' avec les architectures à exécution dans le désordre. Les architectures ''dataflow'' dynamiques implémentent une sorte de renommage de registres. Le ''tag'' est équivalent à une sorte de numéro de registre physique, obtenu après renommage de registre. Sur une architecture ''dataflow'' statique, une instruction productrice écrit son résultat dans une adresse toujours fixe. Avec les dynamiques, les adresses sont renommées comme un registre le serait avec le renommage de registres, une même adresse logique correspond à plusieurs adresses logiques, le ''tag'' permettant de sélectionner la bonne adresse.
===La gestion des ''tags'' des architectures ''dataflow'' dynamique===
Avec l'usage de ''tags'', l'instruction n'utilise plus des bits de présences pour chaque opérande. Dorénavant, une instruction s'exécute une fois que tous ses opérandes ayant le même tag sont disponibles. Sur les architectures statiques, les opérandes avaient un emplacement pré-réservé dans les instructions mêmes, mais les architectures dynamiques ne le permettent pas. C'est maintenant aux opérandes d'indiquer quelle l'instruction qui doit les manipuler, quelle est son adresse.
Toute la difficulté est la production des ''tags''. La gestion des ''tags'' peut être gérée directement dans le jeu d'instruction, avec des instructions dédiées à la gestion des tags. Mais le processeur peut aussi se débrouiller sans aide du logiciel. En théorie, les ''tags'' sont altérés à chaque fois qu'on entre dans une boucle ou qu'on exécute une fonction. Une implémentation partielle attribue un ''tag'' à chaque opérande, initialement à 0. Les instructions de calcul propagent les ''tags'', c'est à dire que le ''tag'' d'un résultat est une copie du ''tag'' de leurs opérandes. Par contre, les instructions liées aux boucles ou les appels de fonction altèrent le ''tag'' passé en entrée.
Pour les instructions liées aux boucles, elles doivent générer un nouveau ''tag'' à chaque itération de la boucle. Typiquement, elles incrémenter le ''tag'' à chaque nouvelle itération. Le ''tag'' est alors un vulgaire compteur d'itération de boucle, pour les boucles les plus simples. La sortie de boucle peut ou non reset les ''tags'', tout dépend de l'implémentation.
===L'architecture générale d'une architecture ''dataflow'' dynamique===
Les opérandes sont mémorisés à part des instructions, les deux sont dans des mémoires séparées (en clair, les architectures dynamiques sont de type Harvard). Il y a une mémoire RAM pour les opérandes, une autre pour les instructions proprement dite, à savoir leur opcode et la liste des instructions consommatrices. L'''Enabling Unit'' est alors scindée en deux : une unité d'appariement (''Matching Unit'') et une unité de chargement/décodage. L'unité d’appariement écrit les opérandes en mémoire, vérifie si les instructions consommatrices ont toutes les opérandes sont disponibles. Si une instruction est prête, elle est envoyée à l'unité chargement, qui lit l'opcode de l'instruction, le concatène avec les opérandes, et envoie, le tout à l'ALU.
[[File:Architecture dataflow dynamique.jpg|centre|vignette|upright=2|Architecture dataflow dynamique.]]
Il faut noter que pour simplifier les choses, les instructions sont considérées comme étant toutes dyadiques, à savoir qu'elles prennent deux opérandes. Et qui dit deux opérandes dit que l'une d'entre elle sera calculée avant l'autre. La première opérande sera mise en attente en attendant que la seconde soit produite. Pour cela, les opérandes sont mises en attente en sortie de l'ALU, dans une '''file d'opérande''', une file de jetons. Les opérandes y attendent tant que l'instruction consommatrice puisse s'exécuter. Une fois qu'une instruction consommatrice a toutes ses opérandes, elles sont retirées de la file d'attente et envoyées à l'unité de chargement/décodage et/ou l'ALU.
Détecter qu'une instruction consommatrice est prête se fait lors de la production d'un résultat/opérande : l'unité d'appariement vérifie si une opérande avec le même ''tag'' et la même adresse d'instruction consommatrice est dans la file d'attente. Pour cela, la file d'attente des opérandes est en réalité une mémoire associative, plus précisément une sorte de cache. Elle mémorise l'opérande, le tag et l'adresse de l'instruction consommatrice. Dès qu'une nouvelle opérande est produite, son tag et l'adresse de l'instruction consommatrice sont comparées aux opérandes en attente. Si une correspondance est trouvée, alors l'instruction associée s'exécute. Sinon, l'opérande est mis en attente.
Afin de simplifier la détection des opérandes prêtes, le jeton peut indiquer si l'instruction consommatrice est dyadique (deux opérandes) ou à une seule opérande. L'idée est que pour les instructions mono-opérande, ont n'a pas besoin de détecter les opérandes prêtes. Dès que l'opérande est produite par l'ALU, on sait que l'instruction est prête, il y a juste à vérifier les ''tags''.
La mise en attente des opérandes fait que les jetons d'autorisation n'ont plus de raison d'exister. Une instruction productrice peut accumuler des opérandes en avance, chacune avec des ''tags'' différents.
===Les mémoires RAM sur les architectures ''dataflow'' dynamiques===
La file d'opérande remplace les registres du processeur, en première approche. Les deux mémorisent les opérandes calculées et destinées à être utilisées plus tard. Et comme le banc de registres, la file d'opérande a une taille limitée. Sauf que si elle est pleine, le processeur est bloqué et ne peut plus démarrer de nouvelle instruction. Pour gérer ce cas problématique, le processeur incorpore une mémoire RAM et un système de gestion de débordement. Le système de gestion de débordement s'occupe d'échanger des données entre RAM et file d'opérandes.
[[Image:Dataflowmanchester.png|centre|vignette|upright=2|Synoptique simplifié de la machine de l'Université de Manchester (1981)]]
Vous avez peut-être l'impression qu'il manque quelque chose à l'architecture précédente, ou du moins que quelque chose cloche. La RAM est utilisée comme une sorte de mémoire étendant la file d'opérande, elle n'est jamais adressée explicitement. Mais alors, comment sont gérées les structures de données ? La réponse est qu'elles demandent d'ajouter une seconde mémoire RAM annexe, spécialisée dans les structures de données; appelée une '''I-Structure'''.
Elles facilitent l'implémentation des tableaux principalement, mais aussi d'autres structures de données secondairement. La lecture ou l'écriture d'une opérande dans une I-structure se fait par l’intermédiaire d'instructions spécialisées, une pour la lecture, une autre pour l'écriture. Elles sont équivalentes aux instructions LOAD et STORE des architectures LOAD-STORE.
Les I-structures sont presque identiques aux mémoires RAM usuelles, la seule différence étant qu'elles sont conçues pour être utilisées avec une architecture taguée. Pour rappel, une architecture taguée ajoute des méta-données à chaque case mémoire. Ici, chaque case mémoire correspond à une opérande complète. Les méta-données sont utilisées pour savoir si une opérande est disponible, ou qu'une case mémoire est réservée pour une future opérande.
Intuitivement, ce sont une amélioration du bit de disponibilité, voire à un ''tag''. Elles mémorisent si la case mémoire est inoccupée, occupée par une opérande valide, réservée pour une future opérande (une instruction a tenté de lire le mot mémoire, mais celle-ci était vide). Si le mot mémoire est dans le troisième cas, l'adresse de l'instruction consommatrice est ajoutée aux méta-données, idem pour le ''tag''.
==Les architectures ''Explicit Token Store''==
les architectures '''''Explicit Token Store''''' se débarrassent du système de ''tag'' des architectures dynamiques et reprennent les bits de présence pour chaque opérande. En théorie, c'est censé poser problème avec les boucles et les fonctions. L'idée est que les opérandes sont dupliquées, avec une copie pour chaque itération de boucle ou chaque instance d'une fonction. Ainsi, chaque fonction ou itération de boucle pourra avoir son propre jeu d'opérandes et de variables locales, permettant ainsi l'utilisation de fonctions ré-entrantes ou récursives et un déroulage matériel des boucles.
Pour cela, les opérandes d'une fonction ou d'une boucle sont stockées dans une structure dédiée. La structure regroupe plusieurs opérandes les unes à côté des autres en mémoire. Chaque opérande est associée à un bit de présence qui dit si l'opérande est disponible ou non. Pour les boucles, il y a autant de structures que d'itérations de la boucle, une par itération. Pour les fonctions, il y a autant de structures que d'instances de la fonction, une par instance.
Des instances ou itérations peuvent s'exécuter en même temps, ce n'est pas un problème car les opérandes seront écrites dans des adresses différentes, alors qu'elles auraient voulu écrire dans un même champ opérande sur une architecture ''dataflow''. L'avantage avec les architectures à token tagué précédentes, est qu'elles n'ont pas besoin d'utiliser une mémoire associative couteuse pour détecter la disponibilité des opérandes. Pas besoin de comparer des ''tags'', il suffit de calculer une adresse à partir du pointeur vers la structure.
Les structures en question peuvent être vues comme des cadres de pile, à la différence près qu'il n'y a pas de pile mais plutôt une liste chainée de structures, voire un arbre de structures. Une autre différence avec la pile des architectures usuelles tient dans le fait que les structures ne sont pas stockées les unes à la suite des autres en mémoire RAM, mais peuvent être dispersées n'importe où et n'importe comment en mémoire. Qui plus est, les cadres de pile ne sont pas organisés avec une politique d'ajout et de retraits de type LIFO.
==Les architectures ''dataflow'' hybrides==
Les architectures dataflow sont certes très belles et créatives, mais celles-ci souffrent de défauts qui empêchent de les utiliser de façon réellement efficace. La détection de la disponibilité des opérandes d'une instruction est assez coûteuse et prend un certain temps. L'immutabilité des variables fait que la gestion des structures de données est laborieuse pour le compilateur et le programmeur, avec un léger cout en performance comparé à un programme impératif. Et ensuite, le plus gros problème de tous : le code exécuté sur ces architectures a une mauvaise localité temporelle et spatiale, ce qui fait que les caches sont peu efficaces.
On peut toujours compenser ces défauts en exécutant beaucoup d'instructions en parallèle, mais rares sont les programmes capables d’exécuter un grand nombre d'instructions à exécuter en parallèle. Il faut dire que les dépendances de données sont légion et qu'il n'y a pas de miracles. On ne peut pas toujours trouver suffisamment d'instructions à paralléliser, sauf dans certains programmes, qui résolvent des problèmes particuliers. En conséquence, les ''architectures dataflow'' ont été abandonnées, après pas mal de recherches, en raison de leurs faibles performances. Mais certains chercheurs ont quand même décidé de donner une dernière chance au paradigme ''dataflow'', en le modifiant de façon à le rendre un peu plus impératif.
Les idées qui vont suivre sont faciles à comprendre si on introduit la notion de ''basic bloc'', '''Bloc de base''' en français. Concrètement, les blocs de base correspondent soit aux corps des boucles, soit aux code d'une fonction. Un bloc de base est un ensemble d'instructions qui, dans un programme normal, s'exécutent les unes après les autres. Sur les architectures ''dataflow'', cette contrainte est relâchée, mais il y a un ordre partiel. Par contre, dans les deux cas, il n'y a pas de branchements dans un bloc de base, ni d'instructions ''switch/merge'' ou tout autre instruction équivalente. Un bloc de base est exécuté quand un branchement/''switch'' saute vers celui-ci,n vers sa première instruction. On quitte un bloc de base par un branchement ou une instruction ''switch''/''merge''.
Les architectures '''''Explicit Data Graph Execution''''' utilisent une approche simple : on découpe un programme en blocs de base qui sont exécutés dans l'ordre, mais avec une exécution dans le désordre à l'intérieur des blocs de base. Les blocs de base ont une taille maximale de 128 instructions. Si l'exécution des blocs de base suit globalement l'ordre séquentiel, l'exécution à l'intérieur se fait dans le désordre, en suivant les dépendances de données. L'encodage des blocs de base suit un encodage similaire à celui des architectures ''dataflow''. Certaines architectures de ce type ont déjà été inventées, comme les processeurs ''WaveScalar'' et ''TRIPS''.
Les '''architectures ''Threaded Dataflow''''' utilisent l'approche inverse. À l'intérieur de ces blocs de base, les instructions s’exécutent les unes à la suite des autres. Par contre, les blocs eux-mêmes seront exécutés dans l'ordre des dépendances de données : un bloc commence son exécution quand tous les opérandes nécessaires à son exécution sont disponibles.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les processeurs VLIW et EPIC
| prevText=Les processeurs VLIW et EPIC
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
n4xogvuzy69w7bq3q89wuwi4utvwccp
Fonctionnement d'un ordinateur/Sommaire
0
69596
774598
774469
2026-10-10T12:25:09Z
Mewtow
31375
/* Les pipelines multicycles simples */
774598
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===Les pipelines multicycles simples===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions|L'émission dans l'ordre des instructions]]
* [[Fonctionnement d'un ordinateur/Le contournement (data forwarding)|Le contournement (data forwarding)]]
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
e221recbvom5jbssi3nrcxk6k0271w3
774601
774598
2026-10-10T12:26:15Z
Mewtow
31375
/* Le parallélisme d’instructions */
774601
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===Les pipelines multicycles simples===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions|L'émission dans l'ordre des instructions]]
* [[Fonctionnement d'un ordinateur/Le contournement (data forwarding)|Le contournement (data forwarding)]]
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
kmi1qkos2aczeurymjvehp21toq9tqo
774622
774601
2026-10-10T12:54:35Z
Mewtow
31375
/* Les pipelines multicycles simples */
774622
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===Les pipelines multicycles simples===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions|L'émission dans l'ordre des instructions]]
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
egs2fbmkxel1fsqy3ab52v78yy5jop4
774764
774622
2026-10-11T01:22:08Z
Mewtow
31375
/* Les pipelines multicycles simples */
774764
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===Les pipelines multicycles simples===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
888q9yoe5vbshbt5qi74iqsp6r6okdx
774765
774764
2026-10-11T01:23:15Z
Mewtow
31375
/* Le parallélisme d’instructions */
774765
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===Les pipelines multicycles simples===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
f7908yole9op73odjnspqslyres9tpl
774766
774765
2026-10-11T01:23:23Z
Mewtow
31375
/* Le parallélisme d’instructions */
774766
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
icuos6sjksl3a4ivij8139t0ekmssa0
774784
774766
2026-10-11T01:44:20Z
Mewtow
31375
/* L’exécution dans le désordre */
774784
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
lxa3244zvylzmqis6yw6ct290mu60x3
Wikilivres:GUS2Wiki
4
78643
774751
773949
2026-10-10T22:05:11Z
Alexis Jazz
81580
Updating gadget usage statistics from [[Special:GadgetUsage]] ([[phab:T121049]])
774751
wikitext
text/x-wiki
{{#ifexist:Project:GUS2Wiki/top|{{/top}}|This page provides a historical record of [[Special:GadgetUsage]] through its page history. To get the data in CSV format, see wikitext. To customize this message or add categories, create [[/top]].}}
Les données suivantes sont en cache et ont été mises à jour pour la dernière fois le 2026-10-10T06:32:25Z. {{PLURAL:5000|1=Un seul résultat|5000 résultats}} au maximum {{PLURAL:5000|est disponible|sont disponibles}} dans le cache.
{| class="sortable wikitable"
! Gadget !! data-sort-type="number" | Nombre d’utilisateurs !! data-sort-type="number" | Utilisateurs actifs
|-
|AncreTitres || 34 || 0
|-
|ArchiveLinks || 13 || 0
|-
|Barre de luxe || 36 || 1
|-
|BoutonsLiens || 42 || 0
|-
|CategoryAboveAll || 20 || 0
|-
|CategorySeparator || 23 || 0
|-
|CoinsArrondis || 100 || 2
|-
|CollapseSidebox || 36 || 1
|-
|CouleurContributions || 33 || 0
|-
|CouleursLiens || 26 || 0
|-
|DeluxeAdmin || 6 || 2
|-
|DeluxeEdit || 36 || 1
|-
|DeluxeHistory || 47 || 3
|-
|DeluxeImport || 19 || 1
|-
|DeluxeRename || 16 || 1
|-
|DeluxeSummary || 37 || 1
|-
|DevTools || 18 || 1
|-
|DirectPageLink || 26 || 1
|-
|Emoticons || 45 || 1
|-
|EmoticonsToolbar || 46 || 1
|-
|FastRevert || 37 || 1
|-
|FixArrayAltLines || 23 || 1
|-
|FlecheHaut || 67 || 2
|-
|GoogleTrans || 28 || 0
|-
|HotCats || 81 || 4
|-
|JournalDebug || 22 || 1
|-
|JournalEnTable || 3 || 1
|-
|LeftPaneSwitch || 6 || 1
|-
|ListeABordure || 30 || 1
|-
|LiveRC || 30 || 0
|-
|LocalLiveClock || 27 || 1
|-
|Logo || 42 || 0
|-
|MobileView || 18 || 2
|-
|NavigAdmin || 39 || 1
|-
|OngletEditCount || 45 || 1
|-
|OngletEditZeroth || 67 || 2
|-
|OngletGoogle || 29 || 1
|-
|OngletPurge || 57 || 3
|-
|OptimizedSuivi || 18 || 0
|-
|Popups || 61 || 2
|-
|RenommageCategorie || 7 || 3
|-
|RestaurationDeluxe || 12 || 1
|-
|RevertDiff || 35 || 4
|-
|ScriptAutoVersion || 17 || 1
|-
|ScriptSidebox || 32 || 0
|-
|ScriptToolbar || 43 || 0
|-
|SisterProjects || 18 || 1
|-
|SkinPreview || 4 || 1
|-
|Smart patrol || 2 || 1
|-
|SourceLanguage || 24 || 0
|-
|SousPages || 57 || 3
|-
|SpaceToolbar || 30 || 0
|-
|TableUnicode || 56 || 0
|-
|Tableau || 67 || 2
|-
|TitreDeluxe || 56 || 1
|-
|TitreHierarchique || 17 || 0
|-
|UTCLiveClock || 27 || 0
|-
|UnicodeEditRendering || 29 || 1
|-
|WikEd || 30 || 0
|-
|autonum || 2 || 0
|-
|massblock || 3 || 1
|-
|monBrouillon || 20 || 1
|-
|perpagecustomization || 17 || 1
|-
|recentchangesbox || 15 || 0
|-
|searchFocus || 19 || 0
|-
|searchbox || 30 || 2
|}
* [[Spécial:GadgetUsage]]
* [[m:Meta:GUS2Wiki/Script|GUS2Wiki]]
<!-- data in CSV format:
AncreTitres,34,0
ArchiveLinks,13,0
Barre de luxe,36,1
BoutonsLiens,42,0
CategoryAboveAll,20,0
CategorySeparator,23,0
CoinsArrondis,100,2
CollapseSidebox,36,1
CouleurContributions,33,0
CouleursLiens,26,0
DeluxeAdmin,6,2
DeluxeEdit,36,1
DeluxeHistory,47,3
DeluxeImport,19,1
DeluxeRename,16,1
DeluxeSummary,37,1
DevTools,18,1
DirectPageLink,26,1
Emoticons,45,1
EmoticonsToolbar,46,1
FastRevert,37,1
FixArrayAltLines,23,1
FlecheHaut,67,2
GoogleTrans,28,0
HotCats,81,4
JournalDebug,22,1
JournalEnTable,3,1
LeftPaneSwitch,6,1
ListeABordure,30,1
LiveRC,30,0
LocalLiveClock,27,1
Logo,42,0
MobileView,18,2
NavigAdmin,39,1
OngletEditCount,45,1
OngletEditZeroth,67,2
OngletGoogle,29,1
OngletPurge,57,3
OptimizedSuivi,18,0
Popups,61,2
RenommageCategorie,7,3
RestaurationDeluxe,12,1
RevertDiff,35,4
ScriptAutoVersion,17,1
ScriptSidebox,32,0
ScriptToolbar,43,0
SisterProjects,18,1
SkinPreview,4,1
Smart patrol,2,1
SourceLanguage,24,0
SousPages,57,3
SpaceToolbar,30,0
TableUnicode,56,0
Tableau,67,2
TitreDeluxe,56,1
TitreHierarchique,17,0
UTCLiveClock,27,0
UnicodeEditRendering,29,1
WikEd,30,0
autonum,2,0
massblock,3,1
monBrouillon,20,1
perpagecustomization,17,1
recentchangesbox,15,0
searchFocus,19,0
searchbox,30,2
-->
o1gqja3km29hk2mf3zuvdu6aycet8id
Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC
0
79441
774772
771169
2026-10-11T01:27:59Z
Mewtow
31375
/* Les branchements */
774772
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des processeurs qui sont capables d’exécuter des instructions dans le désordre, ou plusieurs instructions en parallèle, en même temps. Mais nous nous sommes concentrés sur les processeurs avec un jeu d'instruction normal, où l’exécution des instructions en parallèle est invisible pour le programmeur ou le compilateur. Les processeurs superscalaires sont de ce type, mais ils permettent non seulement d’exécuter plusieurs instructions en même temps, mais aussi d'émettre plusieurs instructions en même temps sur des unités de calcul différentes. Le parallélisme d'instruction maximal avec ce genre d’architectures est donc un processeur pipeliné, superscalaire, à exécution dans le désordre avec renommage de registre.
Mai ces architectures ont un défaut majeur, au-delà de la prédiction des branchements : elles doivent détecter les dépendances et déterminer quelles sont les instructions exécutables en parallèle. Mais il se trouve que ces informations sur les dépendances d'instructions sont connues des compilateurs modernes. Lors de la compilation, le code source est traduit en une représentation intermédiaire où les dépendances de type WAR, WAW et RAR n'existent tout simplement pas. Il faut dire que la représentation intermédiaire a souvent une infinité de registres, voire fonctionne sans registres et avec une infinité de places mémoires. De fait, les processeurs à exécution dans le désordre doivent retrouver des informations qui étaient connues du compilateur mais ont disparu du code source.
De cette observation est venue l'idée de modifier le jeu d’instruction pour que ces informations sur les dépendances soient disponibles directement dans le code machine lui-même. Ainsi, le compilateur fait tout le travail de réorganisation des instructions et d’exécution en parallèle, à la place du processeur. De telles architectures s'appellent des '''architectures à parallélisme d'instruction explicite'''. Il existe plusieurs types d'architectures de ce genre, les deux principales étant les architectures VLIW et ''dataflow''. Il faut aussi mentionner les architectures découplées, fort confidentielles et peu nombreuses, que nous verrons à la fin du chapitre.
Les architectures ''dataflow'' seront vues dans le chapitre suivant. L'idée derrière ces architectures est d'expliciter les dépendances entre instruction et de les encoder directement dans le code machine. Le processeur peut ainsi déterminer quelle instruction est prête à s’exécuter en fonction de la disponibilité des opérandes. L’exécution dans le désordre est donc réalisée par le compilateur. Dans ce chapitre, nous allons voir des architectures à parallélisme d'instruction qui n'encodent pas les dépendances dans les instructions, ou du moins pas explicitement. Voyons maintenant les architectures VLIW, EPIC et découplées.
==Les processeurs VLIW==
Les '''processeurs VLIW''', ou ''very long instruction word'', exécutent plusieurs instructions en parallèle, mais sans forcément avoir de l’exécution dans le désordre. Pour cela, ils regroupent plusieurs instructions indépendantes en super-instructions, appelées des '''faisceaux d’instructions''' (aussi appelés ''bundle''). Le faisceau est chargé en une seule fois et est encodé comme une instruction unique. En clair, les processeurs VLIW chargent "plusieurs instructions à la fois" et les exécutent sur des unités de calcul séparées (les guillemets sont là pour vous faire comprendre que c'est en réalité plus compliqué).
Une autre manière de voir les choses est que les faisceaux d'instruction regroupent plusieurs opérations en une seule super-instruction machine. Là où les instructions machines usuelles effectuent une seule opération, les faisceaux d'instruction VLIW exécutent plusieurs opérations indépendantes en même temps, dans des unités de calcul séparées.
[[File:Vliwpipeline.svg|centre|vignette|upright=1.5|Pipeline simplifié d'un processeur VLIW. On voit que le faisceau est chargé en un cycle d'horloge, mais que les instructions sont exécutées en même temps dans des unités de calcul séparées.]]
===L'attribution des instructions/opérations aux ALUs===
Sur un processeur VLIW pur, les faisceaux sont de taille fixe. Aussi, le regroupement des instructions est grandement facilité, de même que leur attribution aux unités de calcul. L'attribution d'une instruction à une unité de calcul utilise l''''encodage par position'''.
Avec cette méthode, un faisceau est découpé en créneaux (''slot''), chacun étant attribué à une ALU. La position de l'instruction dans le faisceau détermine l'ALU à utiliser. Les opérations/instructions ne peuvent pas être réparties n'importe comment dans un faisceau. Pour le dire autrement, chaque créneau ne peut contenir que quelques instructions bien précises,compatibles avec l'unité de calcul associée. Par exemple, le premier créneau ne peut accepter que des instructions d'addition, le second que des multiplications, le dernier que des instructions transcendantales, etc.
{|class="wikitable"
|-
! colspan="3" |Instruction VLIW à 3 slots
|-
| Slot 1 || Slot 1 || Slot 3
|-
| Addition || Multiplication || Décalage à gauche
|}
En conséquence, il y a de nombreuses contraintes quand au regroupement des opérations/instructions. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre, il faut que les unités de calcul permettent le regroupement. Prenons l'exemple d'un processeur VLIW disposant de deux additionneurs et d'un circuit multiplieur : il sera possible de regrouper deux additions avec une multiplication, mais pas deux multiplications ou trois additions.
Il y a aussi des contraintes sur les registres : les instructions d'un faisceau ne peuvent pas écrire dans les mêmes registres, il y a des contraintes qui font que si telle opération utilise tel registre, alors certains autres registres seront interdits pour l'autre opération, etc.
===L'exécution ''lockstep'' des opérations/instructions d'un faisceau===
Sur un processeur VLIW idéal, toutes les instructions d'un faisceau s'exécutent en même temps, durant les mêmes cycles d'horloge. La conséquence est que si une instruction/opération bloque le pipeline, toutes les instructions du faisceau doivent l'attendre. Par exemple, si un accès mémoire est dans un faisceau, les autres instructions s’exécutent en parallèle, mais on ne peut pas passer au faisceau suivant tant que l'accès mémoire n'est pas terminé. Un faisceau est un tout, qui s’exécute en bloc, on ne peut pas passer au faisceau suivant tant que tout le faisceau précédent est terminé. On parle parfois d''''exécution ''lockstep''''' d'un faisceau.
Un défaut important est la gestion des instructions multi-cycle et notamment des accès mémoire. Lors d'un défaut de cache, les autres unités de calcul sont inutilisées une fois qu'elles ont fait les calculs associés dans le faisceau. Ce ne serait pas le cas avec un processeur à lectures non-bloquantes ou à exécution dans le désordre. Les instructions suivant la lecture pourraient parfaitement alimenter les unités de calcul, à condition d'être indépendants de la lecture. Mais avec un processeur VLIW, impossible de démarrer les opérations du faisceau suivant tant que l'accès mémoire n'est pas terminé. Idem avec les instructions multicycle, pour des instructions flottantes ou les multiplications/divisions.
===Les dépendances de données sont interdites dans un faisceau===
Les instructions/opérations d'un faisceau sont censées être indépendantes, dans le sens où elles n'ont pas de dépendances de données. Aussi, des difficultés arrivent quand des instructions/opérations d'un faisceau manipulent le même registre. Pour les lectures, cela ne pose pas de problème : la donnée lue est envoyée à plusieurs ALU, pas de quoi déclencher des problèmes, il faut juste que la connexion entre registres et unité de calcul le permettent, ce qui est souvent le cas. Les problèmes apparaissent avec des écritures. En théorie, deux instructions/opérations ne sont pas censées écrire dans le même registre. De même, si les instructions sont indépendantes, une instruction ne doit pas lire un registre écrit par une autre instruction. Mais dans les faits, cette situation arrive sur certains processeurs VLIW. Et ils peuvent gérer la situation de trois manières.
La toute première est la plus simple : le processeur interdit à deux instructions d'un même faisceau d'avoir une dépendance de ce type. Si une instruction écrit dans un registre, tout autre instruction du faisceau ne peut pas lire ce registre. Une autre solution autorise ce genre de choses, mais avec une subtilité : la lecture du registre renvoie la valeur avant l'écriture. En clair, les deux instructions n'ont pas de dépendances entre elles, c’est juste que l'instruction lectrice lit une donnée qui est écrasée par la seconde au cycle suivant. L'implémentation est assez simple au niveau du matériel, et assez intuitive.
Il existe cependant une exception, qui permet à plusieurs instructions d'écrire dans un registre. Il s'agit d'une sous-catégories d'instructions à prédicat sur le processeur Itanium. Les instructions en question effectuent une comparaison, puis font un ET/OU/XOR entre le résultat et un registre à prédicat. Or, l'Itanium peut exécuter plusieurs comparaisons de ce type en parallèle, en mettre plusieurs dans un seul faisceau. Si plusieurs comparaisons utilisent le même registre à prédicat et font un ET avec leur résultat, le résultat sera un ET entre tous les résultats des comparaisons. Idem avec un OU ou un XOR sur un même registre. Par contre, impossible de faire à la fois un OU et un ET sur le même registre. Il s'agit donc d'une exception à la règle : pas d'écritures simultanées dans le même registre.
===La gestion des exceptions matérielles avec des faisceaux d'instructions===
Passons maintenant aux dépendances de contrôle, qui existent encore sur les architectures VLIW. L'exécution en bloc, ''lockstep'', est censée résoudre ces dépendances pour ce qui est des branchements. Mais il ne faut pas oublier les exceptions matérielle. Une instruction/opération peut déclencher une exception, et il faut la traiter avec la granularité d'un faisceau. Un processeur VLIW peut gérer la situation de plusieurs manières différentes, qui dépendent du processeur.
La plus simple invalide toutes les instructions du faisceau, l'exception est traitée au niveau du faisceau. Le faisceau est ré-executé intégralement une fois l'exception traitée par la routine adéquate. Une solution complétement à l'opposé exécute toutes les instructions du faisceau, sauf celle qui a levé l'exception. La première fait qu'on doit ré-exécuter toutes les instructions du faisceau, l'autre solution n'en ré-exécute qu'une seule. En terme de consommation énergétique et de performance, les deux sont complétement opposées : ré-exécution maximale couteuse en performance et énergie d'un côté, ré-exécution minimale et peu couteuse en énergie/performance de l'autre. Mais elles ont un point commun : dans les deux cas précédents, les exceptions deviennent alors imprécises.
Une autre solution, plus compatible avec le modèle familier aux programmeurs, ajoute un support des exceptions précises. L'idée est d'invalider uniquement les instructions qui suivent l'exception dans l'ordre du programme. Pour cela, le compilateur doit ajouter quelques informations sur l'ordre des instructions dans le faisceau. Une solution intermédiaire invalide seulement les instructions qui ont une dépendance avec celle qui a levé l'exception. On a alors des exceptions semi-précises, mais les performances sont meilleures vu qu'on n'a pas à ré-exécuter beaucoup d'instructions.
===Le compilateur a un rôle primordial sur les architectures VLIW===
Les architectures VLIW ont des avantages certains : hardware très simple, émission multiple supportée nativement, etc. Mais elles ont aussi divers problèmes, comme une faible compatibilité, des performances limitées par les dépendances existantes à la compilation et une densité de code mauvaise. Tous les avantages et inconvénients majeurs ont la même source : c'est le compilateur qui regroupe plusieurs instructions/opérations en un seul faisceau. Le compilateur garantit que les instructions/opérations regroupées sont indépendantes et peuvent s’exécuter en parallèle.
Un avantage à cela est que les processeurs VLIW ont un hardware très simple, avec peu de circuits de contrôle. Le compilateur se charge de vérifier que des opérations indépendantes sont regroupées dans une instruction, pas besoin de hardware pour cela, pas besoin d'une unité d'émission complexe, ni d'exécution dans le désordre. Il y a juste besoin d'un ''scoreboard'' pour gérer les instructions multicycle et les accès mémoire. Alors qu'avec un processeur superscalaire, il y a des circuits de détection des dépendances entre instructions assez complexes et couteux en circuits.
L'absence de ces circuits fait que les processeurs VLIW étaient utilisés sur les premières cartes graphiques : autant utiliser les transistors pour placer le plus de circuits de calcul possible au lieu d'en dépenser dans des circuits de contrôle. Mais avec l'évolution de la technologie, il est devenu plus rentable d'ajouter de tels circuits pour gagner en performance, ce qui fait que les cartes graphiques incorporent un ''scoreboard'' ou quelque chose de similaire.
Mais pour ce qui est des désavantages, les processeurs VLIW ont une performance très dépendante du compilateur. Le compilateur doit analyser le code source pour détecter les dépendances d'instruction, et faire les regroupements. En soi, analyser les dépendances entre instruction est assez simple pour les compilateurs modernes, qui utilisent une représentation SSA. Mais malgré cela, les compilateurs ne font pas du très bon travail.
Il arrive régulièrement que le compilateur ne puisse pas remplir tout le faisceau avec des instructions indépendantes. Sur les anciens processeurs VLIW, les instructions VLIW (les faisceaux) étaient de taille fixe, ce qui forçait le compilateur à remplir d'éventuels vides avec des NOP, diminuant la densité de code. La majorité des processeurs VLIW récents utilise des faisceaux de longueur variable, supprimant ces NOP. Mais il reste le fait que ces NOP sont une sous-exploitation des unités de calcul.
De plus, certaines dépendances entre instructions ne peuvent être supprimées, ce qu'un processeur à exécution dans le désordre peut faire. Par exemple, le fait que les accès à la mémoire aient des durées variables (suivant que la donnée soit dans le cache ou la RAM, par exemple) joue sur les différentes dépendances. Un compilateur ne peut pas savoir combien de temps va mettre un accès mémoire et il ne peut organiser les instructions d'un programme en conséquence. Par contre, un processeur le peu. Autre exemple : les dépendances d'instructions dues aux branchements, qui ont tendance à limiter fortement les possibilités d'optimisation du compilateur.
Autre défaut : les processeurs VLIW n'ont strictement aucune compatibilité, ou alors celle-ci est très limitée. En effet, le format des faisceaux VLIW est spécifique à un processeur. Celui-ci va dire : telle instruction va sur telle ALU, et pas ailleurs. Mais si on rajoute des unités de calcul dans une nouvelle version du processeur, il faudra recompiler notre programme pour que celui-ci puisse l'utiliser, voire simplement faire fonctionner notre programme. Dans des situations dans lesquelles on se moque de la compatibilité, cela ne pose aucun problème : par exemple, on utilise beaucoup les processeurs VLIW dans l'embarqué. Mais pour un ordinateur de bureau, c'est autre chose...
===Un petit historique des processeurs VLIW===
Les processeurs VLIW sont une idée assez ancienne, qui a ses sources dans un algorithme conçu à base pour l'implémentation d'un microcode horizontal ! Dans les années 80, Joseph Fisher travaillait sur l'architecture du CDC-6600, un super-ordinateur dont le processeur avait un microcode horizontal. Et le microcode horizontal a justement des ressemblances avec les processeurs VLIW.
Frustré par les difficultés à coder un microcode sur ce genre de machines, il chercha un algorithme qui part d'une séquence de micro-opérations, et qui les regroupe dans des micro-opérations horizontales. L'algorithme qui naquit de ces recherches est appelé l''''algorithme de ''trace scheduling'''''. L'algorithme était capable de regrouper des opérations isolées dans un paquet encodé avec une seule micro-opération.
Quelques architectures similaires au VLIW existaient déjà à l'époque, mais étaient prévues pour être codées en assembleur. L'invention de cet algorithme rendit ces architectures bien plus utiles. Le parallélisme exposé dans les micro-instructions horizontales est similaire à celui exposé par les architectures VLIW. Il était alors possible de prendre un compilateur, d'exécuter un algorithme de ''trace scheduling'' sur les instructions, et de se retrouver avec un programme encodant directement les des faisceaux VLIW.
Fisher participa alors à un projet de recherche visant à créer un processeur VLIW, nommé le ELI-512 (''Extremely Long Instruction''-512), dont les instructions faisaient 512 bits et encodaient jusqu’à 30 instructions machines. Par la suite, il créa l'entreprise Multiflow, qui batit les premiers processeurs VLIW commerciaux, les bien nommés processeurs Multiflow. L'entreprise Cydrome tenta de leur faire concurrence. Mais les deux entreprises firent faillite au bout de quelques années.
Quelques tentatives récentes de faire revenir ces architectures sur le devant de la scène ont été des échecs. Citons le cas de l'architecture Itanium d'Intel, ainsi que les processeurs Crusoe de l'entreprise Transmetta. Les deux visaient à remplacer le jeu d'instruction x86 des PC modernes, objectifs très compliqué et sans doute voué à l'échec du fait des problèmes de compatibilité intrinsèques. Transmetta a pourtant pris le problème à bras le corps, avec des techniques de traduction x86-VLIW très performantes, mais purement logicielles. Mais rien à faire, les processeurs VLIW n'ont percé que dans des domaines où la compatibilité avec le code existant n'est pas nécessaire, l'embarqué étant le meilleur d'entre eux. Les processeurs VLIW ont été autrefois utilisés dans les cartes graphiques AMD et possiblement NVIDIA de l'époque de DirextX 8/9. Mais tout cela est le sujet d'un autre wikilivre...
==Les instructions multicyles et le pipeline sur les CPU VLIW==
La discussion précédente partait du principe que le processeur VLIW n'avait pas de pipeline. L'implémentation du VLIW sur un pipeline demande cependant quelques modifications. Le cas le plus simple est celui d'un pipeline de longueur fixe, où toutes les opérations s'exécutent en un seul cycle d'horloge.
Sans système de contournement, l'usage du pîpeline fait qu'il y a un délai entre le moment où une opération lit ses opérandes dans les registres et celui où son résultat est enregistré dans les registres. Quelques cycles d'horloges, tout au plus, mais il s'agit d'un délai qui fait que le résultat d'une opération est enregistré en retard. Or, un processeur pipeliné démarre un nouveau faisceau à chaque cycle d'horloge. Ce qui pose des problèmes si deux faisceaux ont des dépendances de données. Si on lance ces deux faisceaux l'un à la suite de l'autre, le second faisceau ne lira pas le résultat calculé par le premier. On retombe sur les problèmes liés à l'émission dans l'ordre/désordre, que les architectures VLIW souhaitaient éviter.
===Les mitigations et solutions===
Il est possible de mitiger le tout dans le cas d'un pipeline de longueur fixe, où toutes les instructions s'exécutent en un cycle d'horloge. Pour cela, il suffit d'utiliser un système de contournement, pour envoyer les résultats calculés par l'ALU sur son entrée, afin de mieux gérer les dépendances de données de type RAW. Sans cela, deux faisceaux avec une dépendance ne peuvent pas être démarrés l'un après l'autre. Mais la solution ne résout le problème que si toutes les instructions/opérations s'exécutent en cycle d'horloge au niveau de l'ALU. Dans les faits, la solution ne marche pas pour les instructions multicycles, dont les accès mémoire.
Une solution à cela serait de vérifier les dépendances entre faisceaux avec un ''scoreboard'' ou un circuit similaire. En cas de dépendances entre les faisceaux, l'exécution ''lockstep'' des instructions fait qu'on doit ajouter des bulles de pipeline. Le problème est que l'on ne profite pas du pipeline, sauf à utiliser un système de contournement complexe. De plus, cela demanderait d'améliorer l'unité d'émission et d'ajouter du hardware pour cela, ce qui colle mal à la philosophie du VLIW. Mais c'est l'une des seule solution possible pour gérer les accès mémoire.
Une autre solution est de laisser le travail au compilateur, qui gère de lui-même les latences des instructions liées au pipeline. Le compilateur doit donc produire un code machine spécifique à un processeur, qui prend en compte la longueur du pipeline. La solution marche pas trop mal pour les instructions multicycles exécutées dans une ALU, mais pas trop pour les accès mémoire. Pour les instructions multicycles, le compilateur connait la latence de chaque opération et s’arrange pour que le code compilé n'aie pas de problèmes. Par exemple, si une unité de calcul multicycle est utilisée pendant 5 cycles, elle s'arrange pour que les 5 faisceaux suivants ne l'utilisent pas. Le compilateur doit gérer les latences pour chaque opération d'un faisceau, vérifier que des faisceaux consécutifs soient compatibles, et ainsi de suite.
===Les modèles de latence d'instruction===
Peu importe que l'on utilise un ''scoreboard'' ou le compilateur, la latence des instruction a un grand impact dans la manière dont on exécute/compile le code. Le processeur connait la latence maximale d'une instruction multicycle, sauf éventuellement pour les accès mémoire. Idéalement, le compilateur fonctionne mieux avec des latences fixes, mais il peut avoir à se débrouiller avec des latences variables. Les deux cas donnent des résultats très différents pour le compilateur, et ils sont deux modèles différents, qui doivent être pris en compte à la création du processeur.
Avec le premier modèle, la latence des instructions est fixe, à savoir qu'elles prennent toujours le même nombre de cycles pour s'exécuter. Si une instruction de multiplication prend 5 cycles, elle prendra toujours 5 cycles, pas un de moins. Avec ce modèle, le compilateur a plus de facilités à gérer les registres. Par contre, la gestion des exceptions précises est particulièrement complexe. Le processeur doit être conçu pour. Il se peut que l'instruction finisse avant dans l'unité de calcul, mais l'écriture dans les registres sera alors retardée pour respecter la latence fixée. Le chemin de données du processeur doit être conçu en conséquence et doit mettre en attente les résultats disponibles à l'avance.
Le second modèle permet à une instruction multicycle de finir plus tôt que prévu, leur latence est variable. Par exemple, une instruction de multiplication a une latence maximale de 5 cycles, mais il se peut qu'elle prennent 4/3 cycles si les opérandes sont particulières. Le compilateur a un peu plus de mal avec ce genre de modèle. Mais l'implémentation des exceptions précises est bien plus simple. De plus, cela améliore un petit peu la compatibilité dans certains cas. Si le nouveau modèle du processeur a une latence inférieure pour certaines instructions, le code conçu pour l'ancien processeur' marchera toujours, et ses performances seront améliorées.
==Les processeurs VLIW à instruction de longueur variable==
Les processeurs VLIW purs ont de nombreux défauts, mais cela ne signifie pas qu'ils n'ont pas de solutions. Cependant, ces solutions sont un peu opposées à la philosophie de base des processeurs VLIW : réduire au maximum le hardware lié au décodage et aux unités d'émission, tout an gardant l'émission multiple. Les solutions en question sont multiples, mais elle partent du même principe : elle encodent des faisceaux de taille variable.
Par taille variable, on veut dire que les faisceaux ont un nombre d'instructions/opérations variables, qui peut varier d'un faisceau à l'autre. Par exemple, prenons un processeur VLIW codant ses faisceaux sur une taille fixe de 512 bits. En passant à des faisceaux de taille variable, les faisceaux peuvent faire entre 64 et 512 bits, par exemple. Pour cela, les NOPs, les instructions qui ne font rien, sont retirées du faisceau ou sont encodées de manière compressée. Et cela implique que le décodage des instructions et leur émission est plus complexe, elle demande plus de circuits. La densité de code est grandement améliorée, de même que la compatibilité.
Les faisceaux ont donc une taille variable, comprise entre une taille minimale et une taille maximale. La taille maximale est obtenue quand aucun NOP n'est présent dans le faisceau. Par contre, le processeur doit pouvoir charger un faisceau complet. Par exemple, pour un processeur dont les faisceaux font entre 64 et 512 bits, le processeur charge 512 bits en une fois, en un accès au cache d'instruction. Il faut donc faire la distinction entre les faisceaux et les '''paquets de chargement'''. Un paquet de chargement dans l'exemple précédent fait 512 bits, mais les faisceaux en font entre 64 et 512.
L'avantage principal est uen amélioration de la densité de code : pas besoin d'insérer des NOPs à l'intérieur des faisceaux, même si on peut avoir besoin d'insérer des NOPs de bourrage. Avec un faisceau de taille variable, il n'y a pas besoin d'ajouter des NOPs si le faisceau est partiellement remplit, on a juste à raccourcir le faisceau.
Le défaut est que le décodage et le chargement des instructions est plus complexe. Les faisceaux n'ayant plus une taille fixe, il faut utiliser les techniques de chargement/décodage des instructions variables vues dans le chapitre sur l'unité de chargement. Cela fait du hardware en plus, un cout en performance et en énergie. Tout ce que la philosophie VLIW voulait éviter. En contrepartie, le gain en densité de code est important et le gain de compatibilité binaire est très important.
===La délimitation des faisceaux===
Un paquet de chargement peut contenir plusieurs faisceaux. Lorsqu'un paquet est chargé, il est découpé en faisceaux au fur et à mesure de l'exécution. Le processeur exécute les faisceaux les uns après les autres. Par exemple, si toutes les instructions d'un paquet doivent s’exécuter en série, il les exécute une après l'autre, et ne charge le paquet suivant qu'une fois que toutes les instructions du paquet sont terminées. Reste que pour cela, il faut trouver un moyen pour découper un paquet de chargement en plusieurs faisceaux. Pour cela, il existe plusieurs techniques, avec d'autres techniques dérivées.
La première technique ajoute des '''méta-données''' au début du faisceau, à savoir quelques bits qui fournissent des informations sur le faisceau. Et parmi ces information, on peut intégrer la taille du faisceau, sa longueur en nombre d'octets ou d'instructions. Le processeur a juste à extraire la longueur du faisceau et sait comment découper le paquet de chargement en faisceaux. Les faisceaux sont donc placés à la suite les uns des autres en mémoire, mais sont précédés par un octet de méta-données qui indique la longueur du faisceau, comment sont organisées les instructions/opérations dedans, etc. Un défaut est que la taille d'un faisceau est limitée par le nombre de bits utilisés pour encoder les méta-données.
La méthode peut être étendue pour gérer autre chose que la taille des faisceaux, comme on le verra plus bas. En effet, les méta-données ne se bornent pas à donner la longueur du faisceau, mais peuvent indiquer comment sont organisées les instructions dans le faisceau, ou toute autre information utile. A défaut d'intégrer la longueur du faisceau dans l'instruction, on peut intégrer des informations qui permettent de la calculer. Par exemple, nous verrons plus bas la technique du masque de NOPs qui permet de calculer la longueur de l'instruction sur la base d'informations utilisées pour autre chose.
Les deux techniques suivantes dispersent des méta-données entre chaque instruction, au lieu de les regrouper dans un octet au début du faisceau. Pour ce faire, elles ajoutent des bits à la fin de chaque instruction, qui précisent s'il s'agit de la dernière instruction d'un faisceau. La solution est plus flexible, car les faisceaux peuvent avoir des tailles arbitraires, en théorie.
Avec la première technique, chaque instruction est suivie d'un '''bit d’arrêt''' (stop bits) qui indique la fin d'un faisceau.
[[File:Bits d’arrêt.png|centre|vignette|upright=2|Bits d’arrêt.]]
Avec la seconde méthode, chaque instruction/opération est suivie d'un un '''bit de parallélisme''' (''parallel bit''), un bit placé à la fin d'une instruction qui dit si elle peut s'effectuer ou non en parallèle de la suivante.
[[File:Bit de parallélisme.png|centre|vignette|upright=2|Bit de parallélisme.]]
Un exemple est celui des processeurs Texas Instrument de série C6X, notamment le TMS320C62 (C62) et le TMS320C67 (C67). Sur ces processeurs, un paquet de chargement fait 256 bits et les paquets sont eux-même alignés en mémoire sur 256 bits. Les instructions font 32 bits, ce qui fait qu'on peut en mettre 8 par paquet de chargement. Les faisceaux sont indiqués avec des bits de parallélisme.
===L'attribution des instructions/opérations aux ALUs===
Le passage à des faisceaux de taille variable fait que l'on perd la correspondance unique entre une instruction/opération et une unité de calcul. Le fait que les NOPs sont devenus implicites fait que l'on doit répartir les opérations sur les différentes unités de calcul. Il faut déterminer quelle instruction/opération va sur tel unité de calcul. Les solutions pour cela sont assez variables, allant d'un codage des NOPs compressé avec un encodage explicite des NOPs, à des systèmes avec un encodage implicite.
Une solution est de se débarrasser de l'encodage par position utilisé plus haut, où chaque instruction était attribuée à une ALU en fonction de sa place dans le faisceau. A la palce, on le remplace par l''''encodage par nommage''', où chaque instruction d'un faisceau précise l'unité de calcul qui doit la prendre en charge. L'instruction contient un numéro qui indique l'unité de calcul à utiliser. Cette technique est déclinée en deux formes : soit on trouve un identifiant d'ALU par instruction, soit on utilise un identifiant pour tout le faisceau, qui permet à lui seul de déterminer l'unité associée à chaque instruction.
Une autre solution conserve l'encodage par position vu précédemment, mais représente les NOPS sous forme compressée. Les instructions/opérations sont donc toujours à la bonne place, à la bonne position dans le faisceau, mais les NOPs sont compressés et réduits à une taille plus petite. Le décodage doit alors juste identifier les NOPs et les étendre, de manière à retrouver le faisceau d'instruction non-compressé.
Une solution pour cela est d'incorporer, dans le faisceau, un masque qui indique où sont les NOPs dans le faisceau. La technique porte le nom de '''masque de NOP'''. Prenons l'exemple d'un faisceau contenant 8 instructions/opérations maximum. Si j'ai par exemple 6 positions remplies avec deux NOPs, le faisceau contiendra 6 instructions seulement, les NOPs ne seront pas placés dans le faisceau. Par contre, le masque indiquera où sont les NOPs dans le faisceau, où il faut les placer. Le masque contient un bit par instruction, par ''slot'', par position dans le faisceau. Le premier bit est pour la première instruction, le second bit pour la seconde instruction, etc. Le bit est mis à 1 si l'instruction est un NOP, 0 sinon. Les NOPs ne sont donc pas représentés.
{|class="wikitable"
|-
! colspan="8" | Instruction décompréssée
| ADD || MUL || class="f_jaune" | NOP || SUB || class="f_jaune" | NOP || class="f_jaune" | NOP || class="f_jaune" | NOP || LOAD
|-
! colspan="8" | Masque de NOP
| 0 || 0 || 1 || 0 || 1 || 1 || 1 || 0
|-
! colspan="8" | Instruction compressée
| ADD || MUL || SUB || LOAD || 0010 1110 || colspan="3" |
|}
Le masque est généralement placé au tout début du faisceau, pour faciliter le décodage. Il faut noter que cette technique permet de compresser les faisceaux qui contiennent au moins un NOP. Mais les autres sont allongés d'un octet pour stocker le masque. La technique est donc d'autant plus efficace que le code contient de NOPs, ce qui fait qu'elle est surtout utile sur les CPU VLIW avec beaucoup de ''slots'', qui ont beaucoup d’instructions par faisceau, qui ont plus de difficultés à remplir leurs faisceaux.
Il faut préciser qu'avec cette technique, l'encodage de la longueur de l'instruction n'est pas nécessaire. Déterminer la longueur de l'instruction se fait simplement à partir du masque. Il suffit de l'envoyer en opérande d'un encodeur, qui renvoie la longueur du faisceau. La solution a pour avantage de ne pas ajouter d'informations en plus dans le faisceau.
Pour implémenter la technique, l'instruction est décompressée lors du chargement de l'instruction depuis le cache L1. Un circuit prend en entrée un paquet de chargement, et l'analyse, puis décompresse le faisceau en ajoutant les NOPs au bon endroit. L'avantage est que les instructions sont compressées dans le cache, ce qui utilise au mieux sa capacité. Une autre solution déplace le circuit de décompression avant le cache. Les faisceaux sont alors décompressés lors de leur chargement dans le cache d’instruction L1.
===L'alignement des faisceaux dans un paquet de chargement===
Un point important est que les faisceaux peuvent ou non être à cheval sur deux paquets de chargement. Certains processeurs ne le permettent pas. C'est le cas sur les processeurs Texas Instrument de série C6X, où un faisceau doit rentrer intégralement dans le paquet. Ainsi, le bit de parallélisme est à 0 toutes les 8 instructions, vu qu'un paquet de chargement fait 8 instructions.
Un inconvénient est que cela peut parfois laisser des espaces vides à la fin d'un paquet d'instruction. Le compilateur essaye de faire rentrer le plus de faisceaux possibles dans un paquet de chargement, mais il arrive malgré tout qu'il reste de la place. Par exemple, pour un paquet de 8 instructions, imaginons qu'on ait un faisceau de 4 instructions, un autre de 3, et un autre de deux. Les deux premiers faisceaux rentrent dans le paquet, mais pas le troisième. Il manquera une instruction pour compléter, le vide est comblé par une instruction NOP qui ne fait rien. Le ou les NOP en question, sont appelés des '''NOP de bourrage'''.
La compatibilité est très mauvaise avec les bits de bourrage, vu que cela limite la taille du paquet de chargement. De plus, cela réduit les opportunités de compression. On gagne en densité de code si et seulement si on peut mettre plusieurs faisceaux dans un seul paquet de chargement. L'idéal est d'utiliser des paquets de chargement assez grands, pour pouvoir mettre pleins de petits faisceaux dedans.
Mais d'autres processeurs autorisent un système différent, où un faisceau peut être à cheval sur un paquet de chargement. Le chargement se fait paquet de chargement par paquet de chargement, avec un découpage des faisceaux lors du décodage. Les techniques utilisées pour cela sont similaires à celles utilisées pour le chargement des instructions de longueur variable. L'avantage est que cela permet de ne pas avoir à ajouter des NOPs de bourrage. Mais le cout en circuits est conséquent. La technique est notamment utilisée sur l'Itanium, comme on le verra plus bas.
Les techniques pour charger des faisceaux non-alignées demandent d'accumuler les paquets de chargement dans une mémoire temporaire, et de le insérer au bon endroit, à la suite des faisceaux déjà chargés. Le tout implique des circuits de décalage et autres. Et pour qu'ils fonctionnent, ils doivent connaitre la longueur d'un faisceau, afin de décaler du bon nombre de rangs/instructions. Déterminer la longueur d'un faisceau dépend grandement de la méthode employée pour délimiter les faisceaux.
Avec un octet de méta-donnée, la longueur peut être intégrée directement dans les méta-données du faisceau et n'a pas à être déterminée. Avec l'usage de bits d'arrêt ou de parallélisme, on peut la calculer sur la base de ces bits. Il suffit d'envoyer ces bits dans un circuit encodeur qui renvoie la longueur du faisceau, exprimée en nombre d'instructions. Avec un masque pour encoder les NOPs, l'encodage de la longueur se détermine à partir du masque de NOP. Là encore, il suffit de le faire passer dans un circuit qui prend le masque de NOP et détermine la longueur du faisceau. Le circuit n'est ni plus ni moins qu'un circuit de ''population count''.
===L'encodage des faisceaux sur l'Itanium===
L'Itanium utilise un mélange des deux méthodes. Elle regroupe les instructions dans des paquets de trois instructions appelés des ''bundles''. N'allez pas croire que ces ''bundles'' sont des paquets de chargement : les paquets de chargement contiennent 2 ''bundles'', voire plus. Les ''bundles'' ne peuvent pas regrouper n'importe quel triplet d'instructions, mais seulement certains regroupements prévus à l'avance. Encore une fois, il y a des contraintes sur les regroupements d'instructions dans un ''bundle''. Il y a en tout 32 possibilités, qui définissent chacune un mix d'instructions mémoire, entières, flottantes et de branchement.
Les trois instructions d'un ''bundle'' sont associées à un '''octet de ''template''''', qui encode les dépendances entre instructions et qui dit comment découper le paquet de chargement en faisceaux. Il regroupe notamment les trois bits d'arrêt des trois instructions, et 5 bits annexes qui encodent la nature des instructions présentes dans le paquet. J'ai dit plus haut qu'il y a 32 possibilités pour les regroupements possibles, et bien on peut coder les 32 possibilités sur 5 bits. Le tout est codé sur 128 bits, avec 40 bits pour chaque instruction, plus l'octet de ''template''.
[[File:Encodage des stop bits sur l'Itanium.png|centre|vignette|upright=3|Encodage des stop bits sur l'Itanium]]
Un faisceau peut être intégralement contenu dans un ''bundle'' s'il fait trois instructions ou moins. Dans le cas contraire, il est répartit sur plusieurs ''bundles'' et c'est au processeur de reconstituer le faisceau à partir de plusieurs ''bundles''. L'usage de bits d'arrêt permet à un faisceau d'être à cheval sur deux ''bundles'' et éventuellement sur deux paquets de chargement.
Les paquets de chargement chargent 2 ''bundles'' à la fois sur l'Itanium 1 et 2. De plus, les paquets de chargement sont accumulées dans un tampon de 8 ''bundles'' (24 instructions). Le tampon s'assure que deux ''bundles'' soient disponibles pour les unités de décodages, vu que le processeur a la capacité d'exécuter 2 ''bundles'' à la fois dans ses unités de calcul. L'unité de décodage se charge de reconstituer des faisceaux d'instruction en utilisant les octets de ''template'' des ''bundles'' chargés, en analysant les bits d'arrêts et les bits qui encodent le regroupement des instructions.
Avec la technique utilisée sur l'Itanium, la compatibilité binaire est très bonne. Il est possible de changer les unités de calcul du processeur sans que la compatibilité binaire soit altérée. D'ailleurs, l'Itanium n'a lui-même pas de correspondance entre un ''bundle'' et ses unités de calcul, vu qu'il a 6 unités de calcul/mémoire, pour des ''bundles'' de trois instructions. Il pourrait en rajouter ou en enlever sans que ce soit un problème, il faudrait juste adapter le décodeur.
==Les processeurs hybrides VLIW/RISC==
Il existe des processeurs hybrides entre processeurs RISC et VLIW. L'idée est que ces processeurs gérent à la fois des instructions machines isolées, et des faisceaux d'instructions. Il en existe assez peu, mais il est globalement possible de quand même les classer en deux sous-types. Le premier est celui où il est possible de mélanger instructions RISC et VLIW sans véritables contraintes. L'autre est celui où le processeur a deux modes de fonctionnement :un mode RISC, et un mode VLIW, les deux étant totalement séparés et ne pouvant pas être mixés.
===Les CPU RISC/VLIW à instructions mixées===
L'encodage des instructions autorise des instructions isolées, parfois complétées par des faisceaux d'instructions à l'encodage spécifique. Le processeur exécute un programme qui mélange instructions normales et faisceaux VLIW librement.
Un exemple est celui des processeurs Xtensa LX2 de Tensilica. Ils appelent cette technique sous le nom de ''Flexible Length Instruction eXtensions'' (FLIX). Le nom trahit bien que les instructions VLIW sont une extension d'un jeu d'instruction normal, au même titre que les extensions MMX/SSE/x87 et autres du jeu d'instruction x86 s'ajoutent aux instructions x86 normales. Les instructions normales font entre 16 et 24 bits, alors que les faisceaux font soit 32, soit 64 bits. Ils peuvent donc regrouper 2 à 4 instructions normales.
Les processeurs DSP Infineon Carmel utilise une technique similaire. Ils sont des CPU RISC dont les instructions de base font 48 bits. Ils disposent aussi d'instructions courtes codées sur 24 bits. Et enfin, l'extension ''Configurable Long Instruction Word'' (''CLIW'') permet de gérer des faisceaux VLIW qui regroupent six instructions machines. Vu que le processeur charge 48 bits d'un seul coup, il
peut exécuter une à deux instructions courtes d'un seul coup, ce qui en fait une forme basique de VLIW, complémentaire du CLIW.
===Les CPU RISC/VLIW à deux modes de fonctionnement===
Le processeur Intel i860 utilise une méthode différente. Le processeur peut fonctionner en mode RISC normal, ou en mode VLIW. En mode RISC, il peut exécuter des instructions entières ou flottante, les deux étant codées sur 32 bits. En mode VLIW, il exécute des faisceaux VLIW codés sur 64 bits.
L'implémentation du VLIW profite de la présence d'une unité FPU séparée de l'ALU entière. En mode VLIW, les faisceaux d'instruction sont composés en concaténant une instruction entière et une instruction flottante, le tout faisant 64 bits. Les faisceaux sont alignées sur 64 bits, la première instruction est forcément entière, la seconde est une instruction flottante. L'instruction flottante peut faire une multiplication et une addition à la suite, vu qu'on a un additionneur flottant relié à un multiplieur flottant. Les deux circuits ont une interconnexion partiellement programmable, afin de faciliter l'implémentation de certaines instructions flottantes.
==Les processeurs EPIC==
En 1997, Intel et HP lancèrent un nouveau processeur, l'Itanium, dont l'architecture corrigeait les défauts des autres processeurs VLIW. Dans un but marketing évident, Intel et HP prétendirent que l'architecture de ce processeur était une nouvelle classe d'architecture, appelée ''EPIC'' pour '''''Explicit Parallelism Instruction Computing'''''. Mais il faut avouer que cette architecture était juste du VLIW amélioré. De plus, les améliorations apportées par les processeurs Itanium étaient reprises d'autres architectures, ou ont été appliqués sur d'autres processeurs VLIW ultérieurs. Les processeurs de la société Transmetta sont un exemple. Et nous allons les voir dans ce qui suit.
Une architecture EPIC implémente des faisceaux d'instruction de taille variable, mais aussi des techniques de spéculation avancées pour les exceptions matérielles, les branchements, ou les accès mémoire. Vu qu'il s'agit de techniques spéculatives, on s'attend à ce que ce soit le processeur qui soit en charge de ces spéculations, mais la réalité est qu'elles sont une collaboration entre CPU et compilateur !
===Les exceptions différées===
L'Itanium implémente ce qu'on appelle les '''exceptions différées'''. Avec cette technique, la gestion des exceptions est optimisée avec un système de pseudo-transactions. Le code est exécuté par blocs d'instruction, délimités par des branchements ou tout autre limite/barrière pertinente dans le code. L'idée est qu'un morceau de code s'exécute d'un seul coup, et que la détection des exceptions se fait à la toute fin. Si aucune exception n'a été levée, les résultats du bloc de code sont définitivement acceptés. Mais si une exception est levée, tout est annulé et on ré-éxecute le code d'une manière qui permette de gérer l'exception.
La méthode implémente deux types d'instructions : des instructions spéculatives, et des instructions non-spéculatives. Les instructions non-spéculatives lévent des exceptions normalement, si la situation l'exige. Les instructions spéculatives ne font pas cela. A la place, elles mémorisent qu'elles ont levée une exception, mais sans l'exécuter. Tout bloc de code sont donc compilés en deux versions : une version avec uniquement des instructions spéculatives, une avec des instructions normales. La version avec instructions spéculatives s'exécute, et on vérifie si une exception a eu lieu à la fin du bloc de code. Si une exception a eu lieu, on annule tout ce qu'a fait le bloc de code, et on exécute la version non-spéculative du bloc de code.
Pour vérifier l’occurrence d'une exception, chaque registre est associé à un bit ''Exception'', qui est mis à 1 si l'instruction qui a écrit dans ce registre aurait dû lever une exception. Si une instruction lève une exception, elle écrira un résultat faux dans un registre et le bit ''Exception'' est mis à 1. Les autres instructions propageront ce bit ''Exception'' dans leurs résultats : si un opérande est ''Exception'', le résultat de l'instruction l'est aussi. Une fois le code fini, il suffit d'utiliser une instruction qui teste l'ensemble des bits « rien du tout » et agit en conséquence.
Dans la terminologie des processeurs Itanium, ce bit est appelé « rien du tout » (''not a thing''), et est mis à 1 s'il contient une valeur invalide. Son usage est plus général et il ne sert pas qu'à détecter les exceptions, mais aussi des violations de dépendances mémoire, comme on le verra dans la section suivante. De plus, il n'existe que pour les registres entiers. Pour les registres flottants, une solution alternative est utilisée : ils remplacent la valeur dans le registre flottant par une valeur NaTVal, qui est une sorte de NaN spécialisé dans les exceptions.
Pour annuler ce qu'à fait un bloc de code après une exception, le processeur doit mémoriser l'état du processeur avant de démarrer un morceau de code. Pour cela, le processeur sauvegarde les registres du processeur dans des ''shadow registers'', avant d'exécuter un bloc de code, afin que le processeur puisse revenir à l'état de base. Lorsqu'un bloc de code termine son exécution, il exécute une instruction CHECK qui vérifie les bits Exception des registres. Si une exception a eu lieu, l'instruction CHECK branche vers la version non-spéculative du bloc de code. Sinon, elle se comporte comme un simple NOP.
===La spéculation sur les lectures===
L'Itanium fournit une fonctionnalité similaire pour les lectures, qui vise à les déplacer le plus tot possible dans le code. En théorie, le compilateur peut déplacer les lectures, mais il doit respecter les dépendances de données. De nombreuses opportunités sont alors perdues, car le compilateur doit prendre des décisions assez conservatrices. Il ne peut pas déplacer une lecture avant une écriture s'il ne connait pas les adresses à l'avance, quand elles sont calculées à l'exécution. Mais les processeurs EPCI ajoutent de quoi spéculer sur les dépendances mémoire dans le code.
L'idée est d'exécuter les lectures en avance, de détecter les violations de dépendances mémoire à l'exécution et de les corriger quand elles surviennent. Pour cela, on réutilise la machinerie des exceptions différées vue juste avant. Le code est compilé dans une version optimisée où les lectures sont déplacées sans tenir compte des dépendances de données, avec un code de secours non-optimisé. Le processeur vérifie si la spéculation s'est bien passée, avant de décider de passer sur le code de secours ou non. Et la vérification est assez simple : elle est considérée comme une exception matérielle différée. Si une lecture viole une dépendance mémoire, le registre de destination contient une donnée invalide, son bit « rien du tout » est mis à 1, etc.
Pour détecter les violations de dépendance, le processeur maintient une liste des lectures émises dans un cache : la '''table des adresses lues en avance''', appelée en anglais ''advanced load address table'' (ALAT). Cette ALAT stocke l'adresse, la longueur de la donnée lue, le registre de destination, etc. Toute écriture consulte l'ALAT, pour vérifier si une lecture à la même adresse est dans l'ALAT. Si c'est le cas, une dépendance a été violée, la lecture est retirée de l'ALAT, le bit « rien du tout » du registre de destination est mis à 1.
La vérification ne se fait pas de la même façon selon que la lecture ait été déplacée avant un branchement ou avant une écriture. Les deux situations ne sont pas équivalentes. Déplacer une lecture avant une écriture implique une violation de dépendance mémoire. Mais ce n'est pas le cas si on déplace une lecture avant un branchement. Le branchement pourrait en effet brancher vers un morceau de code où cette lecture ne s'exécute pas. Typiquement, le cas est celui où un IF...ELSE a une de ses deux branches qui exécute une lecture et pas l'autre. Dans ce cas, le compilateur déplace la lecture avant le branchement, de manière spéculative. Dans ce cas, on exécute une lecture avant de savoir si elle va réellement être exécutée. Il se peut que la lecture soit exécutée à tord, mais il n'y a pas de violation de dépendance mémoire proprement dit.
[[File:Spéculation sur les lectures.png|centre|vignette|upright=2|Spéculation sur les lectures.]]
Les deux cas sont traités à part en matériel, l'un impliquant l'ALAT et les mécanismes de désambiguïsation mémoire, l'autre demandant de vérifier si un branchement a pris la bonne voie d'exécution. Les mécanisme matériels pour cela ne sont donc pas les mêmes.
* Si on passe une lecture avant un branchement, la lecture et la vérification sont effectuées par les instructions LD.S et CHK.S. Si une dépendance est violée, le processeur lève une exception différée : le bit « rien du tout » du registre contenant la donnée lue est alors mis à 1. CHK.S ne fait rien d'autre que vérifier ce bit.
* Si on passe une lecture avant une écriture, la désambiguïsation de la mémoire est gérée par le compilateur. Tout se passe comme avec les branchements, à part que les instructions sont nommées LD.A et CHK.A.
===Les bancs de registres tournants===
Les processeurs EPIC et VLIW utilisent une forme limitée de renommage de registres pour accélérer certaines boucles. Pour l’expliquer, prenons une boucle simple et intéressons-nous au corps de la boucle, à savoir la boucle sans les branchements et instructions de test qui servent à répéter les instructions. La boucle d'exemple se contente d'ajouter 5 à tous les éléments d'un tableau. L'adresse de l’élément du tableau est stockée dans le registre R2. Dans le code qui suivra, les crochets serviront à indiquer l'utilisation du mode d'adressage indirect. Sans optimisations, le corps de la boucle est le suivant :
loop :
load R5 [R2] / add 4 R2 ;
add 5 R5 ;
store [R2] R5 ;
Les différentes itérations de la boucle peuvent se calculer en parallèle, vu que les éléments du tableau sont manipulés indépendamment. Mais codée comme dessus, ce n'est pas possible car les trois instructions de la boucle utilisent le registre R5 et ont donc des dépendances. Le renommage de registres peut éliminer ces dépendances, mais il n'est pas disponible sur les processeurs VLIW et EPIC. À la place, les concepteurs de processeurs ont inventé les '''bancs de registres tournants''' (''rotating register files''). Avec cette méthode, la correspondance (nom de registre - registre physique) se décale d'un cran à chaque cycle d’horloge. Par exemple, le registre nommé R0 à un instant donné devient le registre R1 au cycle d'après, et idem pour tous les registres. Précisons que sur l'Itanium, cette technique est appliquée non pas à l'ensemble du banc de registre, mais est limitée à un banc de registres spécialisé dans l’exécution des boucles. Évidemment, le code source du programme doit être modifié pour en tenir compte. Ainsi, le code vu précédemment devient celui-ci.
loop :
load RB5 [R2] / add 4 R2 ;
add 5 RB6 ;
store [R2] RB7 ;
Ainsi, le LOAD d'une itération ne touchera pas le même registre que le LOAD de l'itération suivante, idem pour l'instruction de calcul et le STORE. Le nom de registre sera le même, mais le fait que les noms de registre se décalent à chaque cycle d'horloge fera que ces noms identiques correspondent à des registres différents. Les dépendances sont supprimées, et le pipeline est utilisé à pleine puissance.
Cette technique s'implémente avec un simple compteur, incrémenté à chaque cycle d'horloge, qui mémorise le décalage à appliquer aux noms de registre. À chaque utilisation d'un registre, le contenu de ce compteur est ajouté au nom de registre à accéder.
==Les architectures découplées==
Les '''architectures découplées''', aussi appelées '''''Decoupled Access/ExecuteComputer Architectures''''', sont totalement différentes des architectures VLIW. Elles sont bien des architectures à parallélisme d'instruction explicites, mais leur rôle est d'implémenter une forme limitée d’exécution dans le désordre, pas d’exécuter des instructions indépendantes en parallèles. Par contre, elle ont pour point commun avec les architectures VLIW : elles n'encodent pas les dépendances entre instructions de manière explicite dans les instructions, ce que font les architectures ''dataflow''.
Les architectures découplées séparent les accès mémoires des autres instructions dans deux programmes qui s’exécutent en parallèle, ce qui permet une forme limitée d’exécution dans le désordre. Le découpage du programme en plusieurs flux s'effectue à la compilation. Les deux flux sont chacun exécutés sur un processeur séparé, un le processeur ''access'' en charge des accès mémoire et un processeur ''execute'' pour les calculs et des branchements.
Le transfert des opérandes entre processeurs se fait par l'intermédiaire de mémoires tampons de type FIFOs, manipulées par le programmeur. Les transferts se font dans les deux sens : du processeur ''access'' vers le processeur ''execute'' pour les lectures, dans l'autre sens pour les écritures. Les branchements demandent cependant une synchronisation entre les deux flux d'instruction, afin de garantir qu'un branchement s’exécute bien au même moment dans les deux flux. Et toute la difficulté est de synchroniser les deux processeurs.
Cette technique n'est pas compatible avec l’exécution dans le désordre telle que vue dans les chapitres précédents, mais elle en reprend certains avantages. L'avantage principal est que la séparation en deux flux permet de profiter d'une forme limitée d’exécution dans le désordre, mais avec beaucoup moins de structures matérielles, moins de registres, moins de circuits. Les deux flux étant séparés, l'un peut continuer à s’exécuter alors que l'autre est en train d'attendre. Par exemple, le flux de calcul peut continuer à faire des calculs pendant que l'autre est coincé dans un accès mémoire de longue durée. Ou alors, le flux d'accès mémoire peut charger des données à l'avance, pendant que le flux de calcul est bloqué dans un calcul long (une division, par exemple). Cette forme de parallélisme est certes plus limitée que celle permise par l’exécution dans le désordre, mais le principe est là.
: Dans ce qui suit, le processeur ''access'' sera noté processeur A, et le processeur ''execute'' sera noté processeur X, pour plus de simplicité.
Pour en savoir, voici quelques liens utiles.
* [https://course.ece.cmu.edu/~ece740/f13/lib/exe/fetch.php?media=p289-smith.pdf ''Decoupled Access/Execute Computer Architectures'']
===Les échanges de données entre processeurs===
Prenons le cas d'une lecture : la lecture est démarrée par le processeur A, puis est transmise au processeur X. La transmission au processeur X se fait par l'intermédiaire d'une mémoire FIFO, la file de lecture de X (''X load queue'', abréviée XLQ). Quand le processeur X a besoin d'un opérande lu depuis la mémoire, il vérifie si elle est présente dans l'XLQ et se met en attente tant que ce n'est pas le cas.
Pour les écritures, l'adresse de l'écriture est calculée par le processeur A, tandis que la donnée à écrire est calculée par le processeur X, et les deux ne sont pas forcément disponibles en même temps. Pour cela, les adresses et les données sont mises en attente dans deux mémoires tampons qui coopèrent entre elles. La première est intégrée dans le processeur A et met en attente les adresses calculées : c'est la file d’écriture des adresses (''store address queue'', ou SAQ). La seconde met en attente les données à lire, et relie le processeur X au processeur A : c'est la file d’écriture de X (''X store queue'', ou XSQ). Quand l'adresse et la donnée sont disponibles en même temps, l'écriture est envoyée à la mémoire ou au cache.
Les échanges entre processeurs peuvent aussi se faire sans passer par des files de lecture/écriture. Cela peut servir pour diverses scénarios. Par exemple, le calcul d'une adresse sur le processeur A peut utiliser des données calculées par le processeur X. Pour cela, les échanges s'effectuent par copies inter-processeurs de registres. Un registre peut être copié du processeur A vers le processeur X, ou réciproquement. Pour utiliser cette unité de copie, chaque processeur dispose d'une instruction COPY.
===Les branchements===
Les branchements sont présents à des endroits identiques dans les deux flux, ce qui fait que les structures de contrôle présentes dans un programme sont présentes à l'identique dans l'autre, etc. Or, les branchements doivent donner le même résultat dans les deux processeurs. Pour cela, le résultat d'un branchement sur un processeur doit être transmis à l'autre processeur. Pour cela, on trouve deux files d'attente :
* la file A vers X (''access-execute queue'', abréviée AEQ), pour les transferts du processeur A vers le processeur X ;
* la file X vers A (''execute-access queue'', abréviée EAQ), pour l'autre sens.
Il faut noter que le processeur peut se trouver définitivement bloqué si l'AEQ et l'EAQ sont toutes deux totalement remplies ou totalement vide. Pour éviter cette situation, les compilateurs doivent compiler le code source d'une certaine manière.
[[File:752a78c7-71e0-4580-afdd-538Résumé d'une architecture découplée de type access-execute.e30998305.png|centre|vignette|upright=2|Résumé d'une architecture découplée de type access-execute.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les processeurs superscalaires out-of-order
| prevText=Les processeurs superscalaires out-of-order
| next=Les architectures dataflow
| nextText=Les architectures dataflow
}}
</noinclude>
mg16hi8ts49suutenbrro578jngfx98
Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions
0
79799
774600
773099
2026-10-10T12:25:26Z
Mewtow
31375
/* Le Fetch Directed Instruction Prefetching */
774600
wikitext
text/x-wiki
Les processeurs avec un pipeline sont découpés en deux sections : un ''front-end'' qui charge et décode les instructions, et un ou plusieurs ''back-end'' qui exécutent les instructions. L'exécution des micro-opérations est optimisée par des techniques que nous verrons dans la suite du cours : exécution dans le désordre, superscalarité, renommage de registre. Mais un point important est que le ''front-end'' a des optimisations dédiées. La prédiction de branchement est l'une de ces optimisation, mais elle n'est pas la seule. Ce chapitre aborde les optimisations autres que la prédiction de branchement. Nous allons voir le découplage des étages du ''front-end'', le pré-décodage et quelques améliorations liées indirectement à la prédiction de branchement.
==Le prédécodage d'instructions==
La présence d'un cache d'instruction permet l'implémentation de certaines optimisations, dont la plus connue est la technique dite du '''prédécodage'''. Avec elle, lorsque les instructions sont chargées dans le cache d'instruction, elles sont partiellement décodées, grâce à un circuit séparé de l'unité de décodage d'instruction. Le décodage de l'instruction proprement dit est plus court, car une partie du travail est faite en avance, on gagne quelques cycles.
[[File:Prédécodage des instructions dans le cache L1.png|centre|vignette|upright=2.5|Prédécodage des instructions dans le cache L1]]
Le prédécodage est particulièrement utile avec des instructions de taille variable : il permet de pré-déterminer où commencent/terminent les instructions dans une ligne de cache, indiquer leur taille, etc. Autre possibilité, le prédécodage peut indiquer s'il y a des branchements dans une ligne de cache et où ils se trouvent, ce qui est très utile pour la prédiction de branchement.
Pour chaque ligne de cache, le décodage partiel fournit des informations utiles au décodeur d'instruction. Les informations pré-décodées sont soit intégrée dans la ligne de cache, soit mémorisées dans une banque séparée. En clair : une partie de la capacité totale du cache d'instruction est utilisée pour les informations de pré-décodage. Le prédécodage est donc un compromis : un cache d'instruction de plus faible capacité, mais un décodage plus simple.
Le pré-décodage est surtout utile pour les instructions qui sont ré-exécutées souvent. Pour les instructions exécutées une seule fois, le gain en performance dépend de l'efficacité du préchargement et d'autres contraintes, mais ce qui est gagné lors du décodage est souvent partiellement perdu lors du prédécodage. Par contre, si une instruction est exécutée plusieurs fois, le pré-décodage est fait une seule fois, alors qu'on a un gain à chaque ré-exécution de l'instruction.
===Les sélecteurs de branchement intégrés au cache L1===
Le pré-décodage peut être utilisé afin de faciliter le travail de la prédiction de branchement. L'idée est d'incorporer une partie de la prédiction de branchement dans le cache L1 d'instruction. Une ligne de cache mémorise alors des informations de prédiction de branchement dans ses bits de contrôle. Les informations en question peuvent être des adresses de destination, ou simplement de quoi déterminer si le branchement est pris ou non. Il s'agit d'une démarche inverse à celle de la ''Fetch Target Queue'', qui découple l'unité de prédiction de branchement du cache, en insérant une mémoire FIFO entre les deux. Là, la démarche est au contraire de fusionner partiellement cache d'instruction et unité de prédiction de branchement.
Les premiers processeurs AMD utilisaient cette technique, au moins dans les grandes lignes. Une ligne de cache contient potentiellement plusieurs branchements, dont la position est identifiée par le prédécodage. Pour chaque octet, la ligne de cache associe un bit de contrôle qui indique si un branchement démarre à cet octet, si c'est le premier octet d'un branchement. Le prédécodage peut identifier entre un et plusieurs branchement par ligne de cache, il y a une limite. Le prédécodage n'identifie typiquement que les 3 à 5 premiers branchements, les suivants sont ignorés, faute de place dans les bits de contrôle.
Prenons par exemple une ligne de cache de 8 octets, dans laquelle on a 2 branchements de 2 octets chacun.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Branch 1 || bgcolor="#FFFF00" | Branch 1 || Instruction || bgcolor="#FFFF00" | Branch 2 || bgcolor="#FFFF00" | Branch 2 || Instruction || Instruction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 0 || 1 || 0 || 0 || 1 || 0 || 0 || 0
|}
Il est possible d'améliorer le tout en précisant quel est le type du branchement. Par exemple, on peut distinguer les branchements inconditionnel et conditionnels, ou encore les instruction de retour de fonction. L'intérêt n'est pas évident, mais c'est lié au fait que les branchements inconditionnels sont toujours pris, et que les retour de fonction ont une adresse de destination qui est prédite par une unité de branchement séparée, le ''return adress predictor'', pas par un BTB. Deux bits suffisent pour indiquer : si c'est un branchement conditionnel, inconditionnel, un retour de fonction, ou une instruction qui n'est pas un branchement.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Saut inconditionnel || bgcolor="#FFFF00" | Saut inconditionnel || Instruction || bgcolor="#A00000" | Branch cond || bgcolor="#A00000" | Branch cond || Instruction || bgcolor="#F0F000" | Retour de fonction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 00 || 01 || 00 || 00 || 10 || 00 || 00 || 11
|}
L'idée est alors d'ajouter, pour chaque branchement détecté, un '''sélecteur de branchement''' qui indique si le branchement est pris ou non. En clair, des informations de prédiction de branchement sont ajoutés à chaque octet de position. Intuitivement, on se dit qu'il y a seulement un bit par branchement, qui indique si le branchement est pris ou non.
Les prédictions peuvent venir soit de l'unité de prédiction de branchement, soit provenir du prédécodage. Le prédécodage peut faire de la prédiction statique. Elle peut notamment détecter les branchements inconditionnels et les marquer comme pris. Elle peut aussi détecter les branchements conditionnels et le marquer comme non-pris par défaut. L'unité de prédiction de branchement met à jour les sélecteurs de branchements si besoin, pour les branchements conditionnels.
===L'incorporation du ''Branch Target Buffer'' dans le cache d'instruction===
Une première optimisation permet de se passer de ''Branch Target Buffer''. Pour rappel, celui-ci est un cache qui mémorise, pour chaque branchement, quelle est son adresse de destination. Il peut contenir d'autres informations de prédiction, mais laissons-les de côté pour le moment.
L'idée est de déplacer les adresse de destination des branchements dans le cache d'instruction, dans les lignes de cache. Si une ligne de cache contient un branchement, elle mémorise l'adresse de destination de ce branchement, en plus des bits de pré-décodage. En général, les processeurs ne supportent qu'une seule adresse de destination. Si il y a plusieurs branchements dans une ligne de cache, c'est l'adresse de destination du premier branchement pris dans cette ligne de cache qui est mémorisée. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela.
Il faut cependant remarquer qu'à ce petit jeu, les instructions de retour de fonction sont à part. Leur adresse de destination est souvent donnée par une unité de branchement séparée, le ''return adress predictor'', séparée du ''Branch Target Buffer''. Leurs adresses de destination n'ont pas forcément besoin d'être mémorisées dans les lignes de cache.
La technique décrite ici est simple à comprendre. Cependant, les processeurs AMD anciens, d'architecture K6 à K10 n'utilisaient pas cette méthode, mais une variante plus complexe, capable de prédire jusqu'à deux adresses de destination par branchement. A partir de l'architecture K6, le prédécodage déterminait la position des branchements dans les lignes de cache, dans une limite de 4 branchements par ligne de cache. Pour chaque branchement, la ligne de cache mémorisait un sélecteur de branchement, codé sur 2 bits. La valeur des bits indiquait que le branchement n'est pas pris si elle vaut 00, que c'est une instruction de retour de fonction si elle vaut 01, qu'il faut brancher à l'adresse de destination X si elle vaut 10, qu'il faut brancher à l'adresse de destination X si elle vaut 11. Les adresses de destination sont quand à elles mémorisées dans un cache séparé, appelé le ''Branch Target Cache''. Le mécanisme pour adresser ce cache à partir du cache d'instruction n'est pas très détaillé dans la documentation d'AMD.
===Les avantages et inconvénients===
L'avantage de faire ainsi est que la prédiction de branchement est plus rapide. Lire une instruction depuis le cache renvoie non seulement l'instruction lue, mais aussi des informations de prédiction de branchement. L'unité de prédiction de branchement peut alors utiliser ces informations au cycle suivant pour savoir quelle est l'instruction suivante à charger.
Un défaut de cette approche est que si le branchement à prédire n'est pas dans le L1 d'instruction, aucune prédiction de branchement ne peut être faite et le préchargement ne peut pas fonctionner. C'est une limitation que n'ont pas les BTB découplées du cache L1 : elles peuvent prédire les adresses de destination et la direction d'un branchement, tant que l'entrée associée est dans le BTB. Et l'entrée peut être conservée, même si l'instruction en question a quitté le cache L1 et qu'elle est dans le L2, le L3 ou même en mémoire RAM. Les prédictions peuvent même servir à précharger les instructions utiles.
Sur l'Itanium et l'AMD Opteron, une optimisation assez intéressante permet de conserver les prédictions de branchement lorsque l'un branchement est évincé du cache L1 et se retrouve dans le cache L2. En théorie, les informations de prédiction, présentes dans la ligne de cache, sont perdues lorsque le branchement est évincé. Mais ces processeurs conservent ces prédictions dans un cache séparé, appelé le '''''L2 Branch Cache'''''.
==Le découplage du ''front-end''==
Le séquenceur d'un processeur contient une unité de chargement, un décodeur et un chemin de données. Avec un pipeline, le couplage de ces structures fait que si l'une d'entre elle prend plus d'un cycle pour faire son travail, les étages précédents et/ou suivants sont stoppés. Par exemple, lors d'un défaut dans le cache d'instruction, l'ensemble stoppe. De même, si jamais le décodeur doit décoder une instruction via le micro-code, cela prend plusieurs cycles : l'unité de chargement et le cache d'instruction sont inutilisés. Même chose si jamais une instruction multicycle s’exécute dans le pipeline : cela bloque toutes les étapes précédentes.
Mais il s'agit là d'un défaut inhérent aux pipelines basiques, qui relient chaque étage avec des registres. Avec un peu d'astuce, il est possible que certains étages prennent de l'avance même si l'étage suivant est bloqué. Par exemple, si le décodeur bloque le pipeline en utilisant son micro-code pendant 4-5 cycles, l'unité de chargement peut en théorie précharger à l'avance les instructions suivantes, et les mettre en attente. Ou encore, en cas de bulle de pipeline (''pipeline stall''), le décodeur peut décoder en avance des instructions et les mettre en attente tant que la bulle de pipeline est en cours.
Dans les deux cas, une unité prend de l'avance et met en attente ses résultats tant que l'étage suivant est occupé. La mise en attente est réalisée en remplaçant/complémentant les registres du pipeline avec des mémoires FIFOs. Elles remplacent ou complémentant les registres de pipeline, les deux sont possibles. Si elles remplacent ces registres, si aucune mise en attente n'est requise, elles fonctionnent comme un registre de pipeline.
===La file d'instruction===
[[File:File de micro-opérations.png|vignette|upright=1|File d'instructions]]
Les processeurs modernes intègrent une '''file d'instruction''', à savoir une mémoire FIFO placée entre le cache d'instruction et le décodeur d'instruction. Les instructions chargées par l'étape de chargement soient accumulées dans la file d'instructions et sont décodées quand l'unité de décodage est prête. La file d'attente permet de charger des instructions à l'avance, permettant ainsi de masquer certains accès au cache ou à la mémoire assez longs.
L'idée est que les instructions s'accumulent dans la file d'instruction si le processeur exécute les instructions moins vite qu'il ne les charge. C'est généralement signe qu'il effectue une instruction multicycle et/ou qu'il effectue un accès à la mémoire. À l'inverse, la file d'attente se vide quand le processeur exécute les instructions plus vite qu'il n'en charge. C'est généralement signe qu'un défaut de cache d'instruction est en cours. La présence d'une file d'attente fait que la première situation est compensée lors de la seconde. Les temps d'attentes liées aux instructions multicycles permettent de remplir la file d'attente, qui est ensuite vidée en cas de défaut de cache. Le processeur exécute en permanence des instructions, sans interruption. Alors que sans file d'attente, les défauts de cache entraineront des temps d'attente où le processeur s’exécuterait rien.
La seule limite de cette optimisation est l'influence des branchements. Lorsqu'un branchement est décodé, ce tampon d’instructions est totalement vidé de son contenu. Ce n'est ni plus ni moins ce que faisait la ''prefetch input queue'' des anciens processeurs Intel, dont nous avions parlé dans le chapitre sur l'unité de chargement et le séquenceur.
===La file de micro-opérations===
[[File:File de micro-opération.png|vignette|upright=1|File d'instruction]]
L'optimisation précédente peut aussi s'appliquer entre le décodeur d'instruction et le chemin de données. Pour cela, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction. Elle mémorise les micro-opérations émises par le décodeur et les met en attente tant que le reste du pipeline n'est pas prêt. Nous l'appellerons la '''file de micro-opérations''', mais elle porte de nombreux noms et la terminologie des différents fabricants est assez confuse. Le schéma ci-contre indique que la file de micro-opérations est située en sortie de l’unité de décodage, avant l'unité d'émission et avant l'unité de renommage de registres (que nous aborderons dans quelques chapitres).
La file de micro-opérations permet aux décodeurs de faire leur travail même si le reste du pipeline n'est pas prêt. Par exemple, imaginons que le processeur ne peut pas émettre de nouvelle instruction, soit car toutes les ALUs sont occupées, soit car il y a un accès mémoire qui bloque le pipeline, peu importe. Sans file de micro-opérations, tout ce qui précède l'unité d'émission devrait être totalement bloqué tant que l'instruction ne peut pas être émise. Mais avec une file de micro-opérations, le pipeline peut charger et décoder des instructions, il a juste à accumuler les instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions décodées quand des bulles de pipeline sont émises.
Et à l'inverse, elle permet d'émettre des instructions quand les unités de décodage/chargement sont bloquées. Le cas classique est celui d'un défaut de cache dans le cache d'instruction. Pendant un défaut de cache, aucune instruction n'est chargée ni décodées durant quelques cycles. Sans file de micro-opérations, le processeur ne peut plus rien faire durant quelques cycles. Mais avec une file de micro-opérations, il se rattrape en émettant les instructions en attente dans la file de micro-opérations, s'il y en a. En clair, si l'unité d'émission a mis en attente des instructions, le processeur se rattrape au prochain défaut de cache d'instruction.
Une autre situation où le décodeur bloque est le cas où certaines instructions mettent du temps à être décodées. C'est notamment le cas de certaines instructions complexes, dont le décodage prend facilement 2 à 3 cycles d'horloge, voire plus. Le pire est le décodage des instructions microcodées, qui peut demander plusieurs cycles. Or, le pipeline demande qu'on décode une instruction par cycle pour éviter de bloquer le pipeline. Mais ce temps de décodage peut être masqué si des micro-opérations sont en attente dans la file, elles sont exécutées pendant le décodage long.
===Le ''Loop Stream Detector''===
Les boucles sont une opportunité d'optimisation très intéressante sur les CPU avec une file de micro-opérations. L'idée est que lors d'une boucle, des instructions sont chargées, décodées et exécutées plusieurs fois de suite. Mais à chaque répétition d'une instruction, le chargement et le décodage donnent toujours le même résultat, seule l'exécution n'est pas la même (les registres renommés sont aussi différents, mais nous verrons cela dans plusieurs chapitres). L'idée est simplement de mémoriser les N dernières instructions décodées et de les ré-exécuter si besoin. Ainsi, on évite de charger/décoder une même instruction machine plusieurs fois, mais de réutiliser les micro-opérations déjà décodées.
L'implémentation la plus simple utiliser la file de micro-opérations comme une sorte de pseudo-cache FIFO. La file de micro-opérations ne supprime pas les micro-opérations une fois qu'elles sont émises. Elle mémorise là où se trouve la dernière micro-opération émise, mais conserve celles qui ont déjà été émises. Un circuit annexe, appelé le '''''Loop Stream Detector''''' (LSD), détecte les boucles dans la file de micro-opérations et optimise leur exécution. Si une boucle adéquate est détectée par le ''Loop Stream Detector'', les micro-opérations de la boucle sont lues dans la file de micro-opération et sont injectées directement dans la suite du pipeline. De plus, les unités de chargement et de décodage sont désactivées pendant l’exécution de la boucle, ce qui réduit la consommation d'énergie du CPU.
L'optimisation accélère les petites boucles, qui tiennent toutes entières dans la file de micro-opérations, et sous condition qu'elles s'exécutent de la même manière à chaque exécution. De telles boucles exécutent une suite de N instructions, qui reste identique à chaque itération de la boucle. Le cas le plus simple est celui d'une boucle dans laquelle il n'y a pas de branchements. Pour les boucles normales, le processeur reprend une exécution normale quand on quitte la boucle ou quand son exécution change, par exemple quand un if...else, un return ou tout autre changement de flot de contrôle a lieu. Vu que toutes ces situations impliquent un branchement qui n'a pas été pris comme avant, le processeur n'utilise plus le ''Loop Stream Detector'' en cas de mauvaise prédiction de branchement.
Le LSD vise surtout à désactiver les décodeurs et l'unité de chargement lors de l'exécution d'une boucle. La désactivation peut être du ''clock gating'', voire du ''power gating'', être partielle ou totale. Dans le pire des cas, les unités de chargement peuvent continuer à charger des instructions en avance dans une file d'instruction, mais les décodeurs peuvent être désactivés. Dans le meilleur des cas, la totalité de ce qui précède la file de micro-opération est désactivé tant que la boucle s’exécute normalement. Y compris le cache de micro-opération.
[[File:Loop Stream Detector.png|centre|vignette|upright=2|Loop Stream Detector]]
Évidemment, la taille des boucles optimisées ainsi est limitée par la taille de la file de micro-opération, ce qui fait que l'optimisation ne fonctionne que pour des boucles de petite taille. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 micro-opérations. De plus, toute la file de micro-opération n'est pas gérée par le ''loop stream detector''. Par exemple, les processeurs avec une file de micro-opération de 64 micro-opération peuvent gérer des boucles de maximum 32 à 40 micro-opérations.
Mais les contraintes principales portent sur la détection des boucles. Le ''Loop Stream Detector'' ne peut pas détecter toutes les boucles qui existent, et certaines boucles ne sont pas détectées. Par exemple, le ''Loop Stream Detector' ne peut pas détecter les boucles si un appel de fonction a lieu dans la boucle. Il y a aussi des contraintes quant au nombre de branchements à l'intérieur de la boucle et le nombre d'accès mémoire.
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les micro-architectures Zen 4 mais il a disparu sur la micro-architecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la micro-architecture Zen 4 d'AMD ou les CPU de micro-architecture Skylake et Kaby Lake d'Intel. Pour la micro-architecture Skylake, les raisons officielles pour cette désactivation sont un bug lié à l'interaction avec l'''hyperthreading''. Il est vraisemblable que des bugs ou des problèmes de sécurité aient amené à la désactivation sur les autres architectures.
===Le cache de micro-opérations===
Le '''cache de micro-opérations''' a le même but que le ''Loop Stream Detector'', à savoir optimiser l'exécution des boucles. La différence avec le ''Loop Stream Detector'' est qu'il y a un cache séparé de la file de micro-opérations, qui mémorise des micro-opérations décodées, dans le cas où elles soient réutilisées par la suite. La première itération d'une boucle accumule les instructions décodées dans le cache de micro-opérations, les itérations suivantes de la boucle lisent les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois. Sur de nombreux processeurs, le cache de micro-opération est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ainsi, seules les micro-opérations émises sont copiées dans ce cache.
[[File:File de micro-opérations et cache de micro-ops.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
Les avantages sont les mêmes qu'avec un ''Loop Stream Detector'' : une consommation énergétique réduite, des performances légèrement améliorées. Le décodeur et l'unité de chargement sont inutiles en cas de succès dans le cache de micro-opération, ce qui fait qu'ils sont désactivés, éteints, ou du moins subissent un ''clock-gating'' temporaire. Ils ne consomment pas d'énergie, seul le cache de micro-opération utilise de l'électricité. L'avantage en termes de performance est plus faible, assez variable suivant la situation, mais aussi bien le cache de micro-opérations que le LSD ne font pas de mal.
Une différence avec le cache de micro-opération est qu'une boucle doit s’exécuter à l'identique avec un ''Loop Stream Detector'', pas avec un cache de micro-opérations. Prenons l'exemple d'une boucle contenant quelques instructions suivies par un IF...ELSE. Il arrive qu'une itération de la boucle exécute le IF, alors que d'autres exécutent le ELSE. Dans ce cas, le ''Loop Stream Detector'' ne sera pas activé, car la boucle ne s’exécute pas pareil d'une itération à l'autre. Par contre, un cache de macro/micro-opération mémorisera les micro-opérations du IF et du ELSE et les fournira selon les besoins. La raison est que le cache de micro-opérations a une politique de remplacement des lignes de cache plus complexe que le FIFO, typiquement une politique LRU ou LFU approximée. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', pour un cout en transistor plus élevé.
Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. L'accès au cache de micro-opération se fait lors de l'étape de chargement. Le cache de micro-opérations est adressé en envoyant le ''program counter'' sur son entrée d'adresse, en parallèle du cache d'instruction. En clair, il y a une voie qui regroupe cache d'instruction, file d'instruction et décodeur, et une seconde voie qui se résume au cache de micro-opération. Les deux voies sont accédées en parallèle. En cas de succès dans le cache de micro-opération, les micro-opérations adéquates sont lues directement depuis le cache de micro-opération.
Le cache de micro-opération associe, pour chaque instruction machine, une ou plusieurs micro-opérations. Avec l'implémentation la plus simple, une ligne de cache est associée à une instruction machine. Par exemple, sur les processeurs Intel de micro-architecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. Une instruction devait tenir toute entière dans une ligne de cache, ce qui fait que les instructions décodées en plus de 6 micro-opérations ne pouvaient pas rentrer dans ce cache.
Il existe deux méthodes différentes pour encoder les micro-opérations dans le cache de micro-opérations. La première est la plus intuitive : on mémorise les micro-opérations dans la ligne de cache, directement. Elle est utilisée sur les processeurs AMD, et sans doute sur les processeurs Intel récents. Mais les anciens processeurs Intel, comme ceux des architectures Sandy Bridge et Netburst, utilisent une autre méthode. Une ligne de cache mémorise non pas les micro-opération directement, mais un pointeur vers le ''control store'', qui indique à quelle adresse dans le micro-code se situe la micro-opération. La micro-opération est donc lue depuis le micro-code lors de son envoi au chemin de données, lors de son émission.
Il faut noter que pour des raisons de performance, le cache de micro-opérations est virtuellement tagué, ce qui fait qu'il est invalidé en cas de changement de programme. Sur l'architecture Sandy Bridge, il est carrément inclus dans le cache L1, les deux sont des caches inclusifs l'un avec l'autre. Les premières implémentations étaient très limitées. Les micro-opérations devaient être séquentielles dans le code, le cache était consulté seulement après un branchement et non à chaque instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Le cache de micro-opérations et le ''Loop Stream Detector'' font la même chose, mais certains processeurs implémentaient les deux. L'avantage est que le cache de micro-opération peut être désactivé si jamais le LSD détecte une boucle dans la file d'instruction, ce qui réduit encore plus la consommation énergétique. En pratique, l'impact sur la consommation énergétique est très difficile à mesurer, mais il rajoute de la complexité pour la conception du processeur.
[[File:File de micro-opérations et cache de micro-ops - Copie.png|centre|vignette|upright=2.5|File de micro-opérations et cache de micro-ops - Copie]]
===La ''Fetch Target Queue''===
Nous venons de voir qu'il est possible de découpler les étages de chargement, décodage et le chemin de données, en insérant des mémoires FIFOs dans le pipeline. Il en est de même avec l'unité de chargement elle-même. Elle est composée de deux circuits qu'on peut découpler : le cache d'instruction, l'unité de prédiction de branchement, et l'unité de calcul d'adresse. L'unité de calcul d'adresse regroupe : le ''program counter'', de quoi l'incrémenter, les MUX associés pour gérer les branchements.
Les processeurs modernes découplent l'unité de calcul d'adresse du cache d'instruction, en insérant une mémoire FIFO entre les deux. Les premiers articles scientifiques, qui ont proposé cette solution, l'ont appelée la '''''Fetch Target Queue''''', abréviée FTQ. Elle accumule les adresses à lire dans le cache d'instruction, peu importe que ces adresses viennent du ''program counter'' ou de l'unité de prédiction de branchement.
[[File:Fetch target queue.png|centre|vignette|upright=2.5|Fetch target queue]]
Elle se remplit quand le cache d'instruction est bloqué, soit à cause d'un défaut de cache, soit à cause d'un pipeline bloqué en amont de l'unité de chargement. Par exemple, si le cache d'instruction est bloqué par un défaut de cache, l'unité de prédiction de branchement peut accumuler des prédictions à l'avance dans la FTQ, qui sont ensuite consommées par le cache d'instruction une fois qu'il est redevenu disponible. De même, si l'unité de prédiction de branchement est bloquée par un évènement quelconque, le cache d'instruction peut consommer les prédictions faites à l'avance.
Une utilisation assez originale de la FTQ s'est vu sur les processeurs AMD d'architectures bulldozer. Sur cette architecture, les cœurs étaient regroupés par paquets de deux, et les deux cœurs partageaient certains circuits. Notamment, l'unité de prédiction de branchement était partagée entre les deux cœurs ! Pourtant, chaque cœur disposait de sa propre FTQ !
==Le ''Branch Folding'' et la file d'instruction==
Une file d'instruction basique précharge les instructions qui suivent la dernière instruction chargée. Mais une telle file d'instruction gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées. De plus, les instructions préchargées par erreurs doivent être supprimées pour éviter qu'elles soient décodées et exécutées, ce qui fait que la file d’instruction doit être invalidée.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Heureusement, il existe des techniques qui visent à réduire l'impact des branchements sur la file d'instruction. L'idée est d'exécuter les branchements à l'avance, avant même leur décodage ! L'optimisation en question est appelée le '''''Branch Folding''''', et a été utilisé sur les processeurs PowerPC, pour ne citer qu'eux.
Le ''branch folding'' a une ressemblance très forte avec le préchargement appliqué aux instructions. Il faut dire qu'une file d'instruction a pour rôle de charger des instructions ''à l'avance''. La différence est la cible : dans la file d'instruction pour le ''branch folding'', dans le cache pour le préchargement d'instruction. Pour faire la distinction avec les autres formes de préchargement, nous parlerons de '''préchargement interne'''.
Les algorithmes utilisés pour le préchargement interne sont cependant les mêmes que pour le préchargement d'instruction. Aussi, nous allons les voir en même temps. Le préchargement séquentiel a pour équivalent... la file d'instruction elle-même. Du moins, dans son fonctionnement normal. Voyons maintenant les autres algorithmes.
===Le ''target line prefetching''===
Une version améliorée prend en charge les branchements d'une manière un peu plus naturelle. Elle agit comme le préchargement séquentiel, sauf quand elle rencontre un branchement inconditionnel. Dans ce cas, elle précharge les instructions à partir de l'adresse de destination du branchement. Il s'agit du '''''target line prefetching'''''.
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
La technique demande de détecter les branchements inconditionnels. Pour cela, une unité spécialisée scanne la file d'instruction, pour y trouver les branchements. L'unité en question s'appelle l''''unité de ''branch folding'''''. Si un branchement inconditionnel est détecté dedans, elle utilise le ''target line prefetching''. L'adresse de destination est extraite du branchement inconditionnel, et le préchargement continue à partir de cette adresse. Pour cela, le ''program counter'' est altéré en avance. De plus, le branchement est remplacé par l'instruction de destination du branchement. Les instructions qui "suivent" le branchement sont annulées avant, histoire d'éliminer les instructions chargées à tord.
[[File:Branch folding.png|centre|vignette|upright=2|Branch folding.]]
Maintenant, qu'en est-il des branchements conditionnels ? La réponse de si la prédiction de branchement est utilisée ou pas. Sans prédiction de branchement, les branchements conditionnels sont considérés comme non-pris. Avec prédiction de branchement, tout dépend de si le branchement est prédit comme pris ou non. Si le branchement est considéré comme pris, il est traité comme un branchement inconditionnel : il est remplacé par l'instruction de destination et le ''program counter'' est altéré. Si le branchement est non-pris, il suffit de ne rien faire et de continuer le préchargement séquentiel.
Notez qu'il faut que l'adresse de destination soit connue à l'avance, ou du moins puisse être calculée facilement (branchements relatifs). L'unité de branchement peut contenir un additionneur afin de calculer l'adresse de destination d'un branchement relatif.
===Le préchargement du mauvais chemin===
Le ''target line prefetching'' peut être associé avec le préchargement séquentiel, pour gérer les branchements conditionnels au miuex. L'idée est de précharger à la fois les instructions après le branchement et celles à sa destination. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates auront été préchargées. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching''). La technique était implémentée sur le mainframe IBM 370/165 et quelques modèles IBM similaires. Les processeurs PowerPC utilisaient aussi cette technique.
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le préchargement du mauvais chemin demande d'utiliser deux files d'instructions séparées. L'une dans laquelle on précharge de manière séquentielle, l'autre utilisant la ''target line prefetching''. Le choix de la bonne file se fait avec un multiplexeur. Une fois le branchement conditionnel exécuté, on sait s'il est pris ou non, le contenu de la file adéquate est conservé, l'autre est intégralement invalidée.
[[File:Branch folding avec prédiction du mauvais chemin.png|centre|vignette|upright=2|''Branch folding'' avec prédiction du mauvais chemin.]]
Le préchargement du mauvais chemin réduit un peu la pénalité en cas de mauvaise prédiction de branchement, en économisant quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque charger l'instruction de destination déclenche un défaut de cache. Le préchargement déclenche alors le défaut de cache en avance de quelques cycles, qui peuvent être traité avant même que la mauvaise prédiction de branchement soit détectée.
Elle a le défaut de bouffer beaucoup de débit binaire, au niveau du cache d'instruction. Elle fonctionne mieux si l'on peut précharger deux instructions par cycle, une par chemin. Et cela demande un cache d'instruction double port, dont le cout en circuit n'est pas négligeable. Et le gain en performance est assez faible.
Les premiers processeurs PowerPC utilisaient cette optimisation. Ils avaient une file d'instruction de 8 instructions, et l'unité de branchement scannait les 4 premières instructions. L'unité de ''branch folding'' contenait les registres nécessaires pour gérer les branchements. Elle contenait précisément le ''link register'' pour des appels de procédures, le ''Count Target Register'' et le ''Count Register'' utilisés pour les boucles. Elle contient aussi un additionneur pour calculer les adresses des branchements relatifs.
L'unité de ''branch folding'' identifiait les branchements, sans décoder les instructions en attente. A la place, les instructions étaient pré-décodées dans le cache d'instruction, qui identifiait la position des branchement. Les informations de prédécodage étaient utilisées pour remplir la file d'instruction. Chaque instruction dans la file d'instruction est associée à un bit qui indique si c'est un branchement ou non. L'unité de branchement anticipé peut alors détecter les branchements avec un simple encodeur à priorité.
[[File:Branch folding des CPU PowerPC.png|centre|vignette|upright=2|Branch folding des CPU PowerPC - les bits de prédécodage sont en couleur. Les branchement sont en rouge, les autres instructions en jaune.]]
===Le préchargement du mauvais chemin avec plusieurs branchements consécutifs===
Un défaut du préchargement du mauvais chemin est qu'elle gère mal le cas où deux branchements sont assez proches dans le code machine. Un branchement scinde le flux d'instructions : un où le branchement est pris, un autre où il ne l'est pas. Chacun de ces flux peut lui-même contenir un branchement, et se scinder lui aussi. Et ainsi de suite. Et le processeur doit gérer cette situation en termes de préchargement.
[[File:Exécution stricte 04.png|centre|vignette|upright=2|Exécution stricte]]
Plusieurs solutions existent. La première est de ne pas utiliser le ''target line prefetching'' au-delà de deux branchements. La méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Une solution un peu moins intuitive utilise le préchargement du mauvais chemin pour le premier branchement, mais bascule sur le préchargement séquentiel pour les branchements suivants. Cette solution est celle qui est systématiquement implémentée, car elle est simple à implémenter. Une dernière possibilité est d'utiliser la prédiction de branchement au-delà du premier branchement, afin de poursuivre le chargement de manière spéculative.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
Une autre possibilité consiste à utiliser le préchargement du mauvais chemin pour le second branchement, ainsi que ceux des flux extérieurs. Cette dernière est impraticable car elle demande des caches avec un grand nombre de ports et la présence de plusieurs files d'instructions, qui sont utilisées assez rarement. Et quitte à gaspiller autant de transistors, autant les utiliser décoder/exécuter directement les instructions, au lieu de les accumuler dans des files d'instruction. C'est ce qu'on appelle l''''exécution stricte''' (''eager execution'').
Quelques papiers de recherche ont étudié l'idée, mais ses défauts font qu'elle n'a jamais été utilisée dans un processeur en dehors de prototypes de recherche. Le gros problème de l'exécution stricte est qu'on est limité par le nombre d'unités de calculs, de registres, etc. Autant ce serait une technique idéale sur des processeurs avec un nombre illimité de registres ou d'unités de calcul, autant ce n'est pas le cas dans le monde réel. Au bout d'un certain nombre d’embranchements, le processeur finit par ne plus pouvoir poursuivre l’exécution, par manque de ressources matérielles et doit soit stopper, soit recourir à la prédiction de branchement.
==Le préchargement d'instructions==
Dans le chapitre sur le préchargement, nous avons volontairement mis de côté les techniques de ''préchargement d'instruction'', car ce n'était pas le moment d'en parler. Mais le moment est venu d'aborder le préchargement pour les instructions, d’où cette section. La section précédente parlait d'une forme de préchargement qui n'en est pas vraiment, car elle n'implique pas de mémoire cache. Par contre, le préchargement que nous allons voir le fait. Le '''préchargement d'instruction''' charge des instructions dans le cache L1 d’instruction. Les instructions proviennent du cache L2, qui mémorise aussi bien données qu'instructions. Aussi, nous parlerons de '''préchargement L2-L1i''', sous entendu : du cache L2 vers le cache L1 d'instruction.
Pour comprendre comment s'effectue le préchargement L2-L1i, rappelons que l'unité de chargement émet des adresses consommées par le cache d'instruction. Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Idem en cas de blocage du pipeline au-delà du cache, par exemple en cas de bulle de pipeline prolongée. Et c'est lors de ces blocages que le préchargement d'instruction se met en place. Lors de ces blocages, une unité de préchargement envoie des lectures au cache L2, pour précharger les instructions adéquates. Elle prédit quelles sont les instructions futures soit en se basant sur la prédiction de branchements, soit en regardant le contenu de la FTQ, soit autrement.
[[File:Préchargement d'instructions.png|centre|vignette|upright=2.5|Préchargement d'instructions]]
L'unité de préchargement peut utiliser n'importe quel algorithme de préchargement : du préchargement séquentiel, du ''target-line prefetching'', ou du préchargement du mauvais chemin.
* Avec le préchargement séquentiel, le préchargement charge la ligne de cache suivante à la dernière ligne de cache accédée.
* Le ''target-line prefetching'' s'enclenche en présence de branchements : l'unité de prédiction de branchement donne l'adresse de destination d'un branchement, et c'est la ligne de cache de la destination qui est chargée.
* Avec le préchargement du mauvais chemin, deux lignes de cache sont préchargées : celle avec ''target-line prefetching'', celle avec préchargement séquentiel. Elle n'a de sens que pour les branchements conditionnels.
Le préchargement séquentiel est facile à implémenter, car il n'a pas à utiliser la prédiction de branchement. Il a juste à analyser les derniers accès mémoire du cache d'instruction. Il n'a pas de différence avec un ''prefetcher'' séquentiel, tel qu'on l'a vu dans le chapitre sur le préchargement. Les deux autres formes de préchargement sont elles bien plus compliquées.
===L'anticipation du ''program counter''===
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
===Le ''Fetch Directed Instruction Prefetching''===
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
{{AutoCat}}
49zkxyt4blzedlg9nux92eunpikvqtd
Mathc initiation/a594
0
81090
774812
774484
2026-10-11T11:43:30Z
Xhungab
23827
774812
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
'''Deux propriétés :'''
* Si f(t) est '''paire''', alors '''bn=0'''.
* Si f(t) est '''impaire''', alors '''a0=0 et an=0'''.
.
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(x) = x ...................... avec x = [0..PI]]]
* [[Mathc initiation/007n|La fonction : f(x) = x^2 .................. avec x = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(x) = (x(x+1)) ........... avec x = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
{{AutoCat}}
oioipj0625an0d9k5gtai9irs7elzdz
Mathc initiation/a593
0
81091
774738
774574
2026-10-10T20:41:46Z
Xhungab
23827
774738
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|Se familiariser avec les series de Fourier avec f : -> x}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|Présentation]]
{{AutoCat}}
gk8d3p1o14t0rtx2h5qz68yxb11bzd4
774743
774738
2026-10-10T21:03:28Z
Xhungab
23827
774743
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|Se familiariser avec les series de Fourier avec f : -> x}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
sb6fwizdsfvlwq1cghegltl3uv3n3v9
774744
774743
2026-10-10T21:21:35Z
Xhungab
23827
774744
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|La fonction : f(x) = x ...................... avec x = [0..PI]}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
2w4d55otpu2zl3xrkvyoz2lrthvl5lm
774745
774744
2026-10-10T21:21:57Z
Xhungab
23827
774745
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [0..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
sb7782u42kz6qa3nabjo8g062vke2pq
774748
774745
2026-10-10T22:01:43Z
Xhungab
23827
774748
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [0..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
r6gssyzfxw4by6mmiw11prcj90yu5an
Mathc initiation/a602
0
81099
774736
774577
2026-10-10T20:38:15Z
Xhungab
23827
774736
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|Se familiariser avec les séries de Fourier avec f: -> (x(x+1))}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a603|f1.h]] ...... '''f(x) = (x(x+1)) avec x = [-1..1]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a604|c1a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a605|c1an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a606|c1bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [-1:1] [-.5:2.] !! plot [-1:4] [-.5:2.]
|-
| [[File:Fourier serie in language c with gnuplot.png|thumb|]] ||
[[File:Fourier serie for my wikibook 4.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007p|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a607|c1fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a616|c1fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9c|Présentation]]
{{AutoCat}}
1b5qkri72mtb2ms9q9h1iz64aaxukf0
774741
774736
2026-10-10T21:01:40Z
Xhungab
23827
774741
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|Se familiariser avec les séries de Fourier avec f: -> (x(x+1))}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a603|f1.h]] ...... '''f(x) = (x(x+1)) avec x = [-1..1]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a604|c1a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a605|c1an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a606|c1bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [-1:1] [-.5:2.] !! plot [-1:4] [-.5:2.]
|-
| [[File:Fourier serie in language c with gnuplot.png|thumb|]] ||
[[File:Fourier serie for my wikibook 4.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007p|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a607|c1fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a616|c1fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9c|c1fsap.c]]
{{AutoCat}}
jdcb7d4clitccb0owz52xbmabp8fyhc
774747
774741
2026-10-10T21:23:17Z
Xhungab
23827
774747
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
==La fonction : f(x) = (x(x+1)) ........... avec x = [-1..1]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a603|f1.h]] ...... '''f(x) = (x(x+1)) avec x = [-1..1]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a604|c1a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a605|c1an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a606|c1bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [-1:1] [-.5:2.] !! plot [-1:4] [-.5:2.]
|-
| [[File:Fourier serie in language c with gnuplot.png|thumb|]] ||
[[File:Fourier serie for my wikibook 4.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007p|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a607|c1fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a616|c1fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9c|c1fsap.c]]
{{AutoCat}}
srywmczyihg1rh6aqotbfxtfpfx4bag
774750
774747
2026-10-10T22:02:30Z
Xhungab
23827
774750
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = (x(x+1)) ........... avec x = [-1..1]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a603|f1.h]] ...... '''f(x) = (x(x+1)) avec x = [-1..1]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a604|c1a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a605|c1an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a606|c1bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [-1:1] [-.5:2.] !! plot [-1:4] [-.5:2.]
|-
| [[File:Fourier serie in language c with gnuplot.png|thumb|]] ||
[[File:Fourier serie for my wikibook 4.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007p|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a607|c1fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a616|c1fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9c|c1fsap.c]]
{{AutoCat}}
i19cnah33ij2gds22qiytqovwk6ih2l
Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance
0
82608
774776
774254
2026-10-11T01:29:07Z
Mewtow
31375
/* Les processeurs de marque Via */
774776
wikitext
text/x-wiki
Dans ce chapitre, nous allons étudier des exemples de processeurs x86, ceux présents dans nos PC. Nous n'allons pas voir les anciens processeurs comme le 286, le 386 ou le 486. Nous allons commencer avec le Pentium 1, et les processeurs commerciaux qui ont suivis. Tous les processeurs que nous allons voir dans ce chapitre sont des processeurs superscalaires. De fait, ce n'est pas pour rien si ce chapitre se situe après le chapitre sur les processeurs superscalaires. Par contre, nous allons voir que certains n'ont pas d'exécution dans le désordre, car elle est arrivée après la superscalarité.
Le jeu d'instruction x86 pose quelques problèmes pour la superscalarité, car c'est un jeu d'instruction CISC, avec tous les défauts que ça implique. Un jeu d'instruction CISC a en effet de nombreuses propriétés qui collent mal avec l'émission multiple, avec la '''superscalarité'''. Il y en a plusieurs, certaines impactent le chargement des instructions, d'autres leur décodage, d'autres l'exécution, etc.
Premièrement, les instructions sont de longueur variable, entre 1 et 15 octets, ce qui complique leur chargement et leur décodage. En pratique, les processeurs chargent un bloc de 32 à 64 octets, et découpent celui-ci en plusieurs instructions. La conséquence est que l'usage d'instructions trop longues peut poser problème. Imaginez qu'un processeur charge un bloc de 16 octets et que celui-ci ne contienne qu'une seule instruction : on ne profite pas de la superscalarité.
Deuxièmement, une partie des instructions est microcodée, faute de mieux. Et cela pose de sérieux challenges pour l'implémentation des décodeurs. Dupliquer le microcode demanderait trop de transistors, ce qui fait que ce n'est pas fait. À la place, il n'y a qu'un seul microcode, ce qui fait que l'on ne peut pas décoder plusieurs instructions microcodées en même temps. Il est cependant possible de profiter de la superscalarité, en décodant une instruction microcodée en parallèle d'autres instructions non-microcodées. Et heureusement, ce cas est de loin le plus fréquent, il est rare que plusieurs instructions microcodées se suivent.'
Troisièmement, la présence d'instructions ''load-up'', qui lisent un opérande en mémoire, peut poser problème, mais est aussi source d'optimisations assez intéressantes. En théorie, une instruction ''load-op'' est décodée en deux micro-opération : une pour lire d'opérande en RAM, l'autre pour faire l'opération arithmétique. Sauf que les processeurs x86 modernes optimisent la gestion des instructions ''load-up''. Par exemple, les premiers processeurs Atom géraient des micro-opérations de type ''load-up'', directement dans le chemin de données !
D'autres processeurs utilisent la technique de la '''micro-fusion''' pour retarder le décodage réel des instructions ''load-up'' assez loin dans le pipeline. Avec eux, une instruction ''load-op'' est décodée en une seule "macro-opération", qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. L'avantage est qu'une macro-opération ne prend qu'une seule entrée dans le tampon de ré-ordonnancement, le ''scheduler'', la file de micro-opération, et les autres structures similaires.
Il faut cependant noter qu'avoir des instructions complexes peut avoir des avantages sur les processeurs superscalaires à exécution dans le désordre. Les avantages sont liés au fait que certaines instructions complexes peuvent être implémentées avec des circuits dédiés, plutot qu'avec une suite de µops ou d'instructions. Les performances sont alors meilleures car une grosse instruction complexe devient une seule µops, ce qui aide le renommage de registres, prend moins de place dans le ROB ou les stations de réservation, etc.
: Pour avoir une petite idée des avantages et inconvénients, je conseille la lecture de cet articles de blog : [https://chipsandcheese.com/p/why-x86-doesnt-need-to-die Why x86 Doesn’t Need to Die]. Il s'agit d'une réponse à cet autre article, plus court : [https://hackaday.com/2024/03/21/why-x86-needs-to-die/ Why X86 Needs To Die].
==Introduction : généralités et historique==
Avant de voir les processeurs dans un ordre chronologique, il est bon de faire quelques généralités, histoire de bien cadrer ce qui va suivre. Les architectures Intel et AMD se classent en deux types : une classe de microarchitectures pour la haute performance, une classe de microarchitectures moins performante mais plus économe.
La première est utilisée sur les PC fixes et parfois sur les serveurs, les processeurs pour ''desktop'' et serveur pouvant être différents. Les microarchitectures pour serveurs sont souvent des variantes des microarchitectures pour ''desktop''. Les serveurs ont surtout besoin d'exécuter un grand nombre d programmes/processus en même temps, ce qui fait qu'elles ont besoin d'un grand nombre de cœurs moins de cœurs performants individuellement. Pour les applications ''desktop'', c'est l'inverse : elles ont besoin de cœurs performants, quitte à en avoir moins.
Pour la seconde catégorie, parler de microarchitecture basse consommation serait peu-être un peu exagéré, car elles ne visent pas non les smartphones et autres appareils embarqué. Mais elles le sont suffisamment pour entrer dans un ordinateur portable, où l'autonomie de la batterie est très importante. Dan ce chapitre, nous allons parler exclusivement des processeurs haute performance, les microarchitectures "basse consommation" seront pour le prochain chapitre.
===Les différences et points communs entre µarchs d'Intel et d'AMD===
Les microarchitectures Intel et AMD ne sont pas très différentes quand on regarde de loin. Elles ont évoluées dans la même direction, avec une introduction précoce de la superscalarité, puis de l'exécution dans le désordre, du renommage de registre, de la prédiction de branchement, d'un cache de µops, etc. Néanmoins, il y a des différences entre les deux qui relèvent de la tendance générale, avec de nombreuses exceptions :
* Intel préfère utiliser un ''scheduler'' centralisé, alors qu'AMD préfère des stations des ''schedulers'' décentralisés.
* AMD sépare les unités flottantes/SIMD des unités entières, elles ont des ports d'émission bien séparés. Intel, en comparaison, préfère mettre une ALU et une unité FPU/SIMD sur le même port d'émission.
Mais il s'agit là de tendance, avec de nombreuses exceptions. Par exemple, les microarchitectures K6 et Bulldozer d'AMD utilisaient une station de réservation centralisé. De même, les micraorchitectures récentes d'Intel tendent de plus en plus à scinder le ''scheduler''s, de plus en plus avec le temps. Le fait est qu'elle a dû grossir avec le temps, au point de devoir être scindée tellement elle prenait de circuits. La microarchitecture Skylake l'a scindée en une station de réservation pour les µops mémoire et une autre pour le reste. La microarchitecture ''Sunny Cove'' a ensuite scindée la station de réservation mémoire en deux : une pour les lectures et une pour les écritures.
Dans ce chapitre, nous ne parlerons pas des unités de prédiction de branchement, ni de la hiérarchie des caches. Et les raisons sont différentes entre les eux. Les unités de prédiction de branchement ont été discutées dans le chapitre sur la prédiction de branchement. De plus, de nombreux processeurs différents utilisent des unités de prédiction de branchement qui ont des algorithmes similaires. Mais la raison principale est que l'unité de prédiction de branchement est complétement détachée du reste du pipeline, cache d'instruction excepté sur certaines microarchitectures. N'importe quelle unité de branchement peut s'adapter à n'importe quel pipeline/microarchitecture, tant que ses ''itmings'' et délais en cycles d'horloge tiennent la route. Il en est plus ou moins de même pour la hiérarchie de caches. Me système mémoire est assez indépendant du reste de la microarchitecture.
Dans ce qui va suivre, vous allez rapidement remarquer quelque chose d'assez évident. Les processeurs x86 ont en quelque sorte deux "pipelines" séparés, assez différents les unes des autres, qui ont évolués en parallèle. D'un côté, il y a le "pipeline" entier/mémoire, qui exécute les opérations entières et les µops mémoire. Il regroupe des ALU entières, des AGU (unité de calcul d'adresse) et les unités mémoire LOAD/STORE. Il communique directement avec la ''Load/Store Queue'', car c'est là dedans que se font les calculs d'adresse. A l'opposé, les calculs flottants sont réalisés dans des pipelines séparés, sauf sur quelques processeurs qui font exception, comme les microarchitectures Core. Nous verrons pourquoi dans la section suivante.
Pour ce qui est des accès mémoire, les microarchitectures Intel et AMD ont évolué différemment. Intel a suivi cette progression :
* Le Pentium 1 utilisait une unité mémoire intégrée dans le pipeline entier.
* Le Pentium 3 a séparé l'unité mémoire du pipeline entier, mais ne restait capable que d'émettre un accès mémoire par cycle.
* La microarchitectures Core a ajouté la possibilité de faire deux émissions mémoire simultanées : une lecture avec une écriture.
* Par la suite, de nouveaux ports de lectures ont été rajoutés.
===Un aparté sur la FPU des processeurs x86===
Les processeurs x86 oint acquis une FPU assez rapidement dans l'histoire. Les processeurs 286 et 386 n'avaient pas de FPU, mais pouvaient faire appel à un coprocesseur spécialisé dans les calculs flottants : le '''coprocesseur x87'''. Le coprocesseur x87 intégrait 8 registres flottants et supportait 8quelques instructions flottante, comme FADD pour l'addition, FSUB pour la soustraction, FMUL pour la multiplication, FDIV pour la division, etc.
Le coprocesseur x87 a été intégré dans le processeur assez rapidement, dès le 486. Pour cela, le 486 intégrait une FPU et les 8 registres flottants. Les instructions FADD, SUB, FMUL, et quelques autres, ont aussi été ajoutées au jeu d'instruction de base du 486. Elles formaient une '''extension du jeu d'instruction x86''', à savoir des paquets d'instructions qui n'étaient pas dans le jeu d’instruction de base, mais qui ont été rajoutés par la suite et sont devenus des standards.
Détail extraordinairement important : le coprocesseur x87 intégrait aussi 8 registres, avec une organisation très spéciale. Les registres n'étaient pas "adressables" avec des noms de registres, ils étaient à la place organisé en pile d'opérande. Vous vous rappelez peut-être que, dans le chapitre "les machines à pile et mémoire-mémoire", j'ai parlé des processeurs avec une pile d'opérande. Le coprocesseur x87 en était un. Et cela posait pas mal de problème pour rendre les opérations flottantes performantes. Notamment, cela posait problème pour le renommage de registres.
Le fait que les registres flottants et généraux soient séparés fait qu'ils sont souvent renommés à part, dans deux unités e renommage différentes. Les registres flottants ont été les premiers à être renommés. De nombreux processeurs renommaient les registres flottants, alors que les registres généraux n'étaient pas renommés. La raison est que quand on un budget en transistor limité, on ne peut pas renommer les deux. Et autant c'est utile de renommer les registres généraux, autant la pile de registre du x87 rendait cela quasiment nécessaire pour avoir des performances convenables.
un autre détail est que les optimisations du renommages sont d'abord arrivées sur le pipeline flottant, avant d'être portées sur le pipeline entier. Un exemple est celui des techniques d'élimination des MOV (''MOV elimination''), qui traitent les instructions MOV entre registres directement dans l'unité de renommage. Ou encore, les techniques avec des idiomes pour mettre à zéro un registre en le Xorant avec lui-même. Elles ont été utilisées d'abord sur les registres flottants/SIMD, avant d'arriver sur les registres généraux. Quelques processeurs les utilsaient pour les registres flottants, mais pas les registres généraux.
Il en est de même avec le renommage dans le ROB versus à banc de registre physique. Quelques CPU renommaient les registres flottants avec un renommage à banc de registre physique, avec que le pipeline entier utilisait encore le renommage dans le ROB. Les processeurs Athlon et Phenom d'AMD étaient dans ce cas, par exemple. Chez Intel, le renommage à banc de registre physique est apparu sur le Pentium 4 et a été appliqué sur tous les registres.
En général, la FPU est précédée par une station de réservation unique (ou par un ''scheduler'' sur certains CPU). Il y a souvent un port d'émission pour l'additionneur flottant et un autre pour le multiplieurs, sauf pour les tout premiers modèles d'Intel et AMD. Il y aussi souvent un port pour les instructions FSTORE, les écritures flottantes. Les ports en question servent à transmettre les données à écrire vers l'unité mémoire, ainsi que l'adresse mémoire associée.
{|class="wikitable"
|+ Station de réservation flottante
|-
! Station de réservation 1 !! Station de réservation 2 !! Station de réservation 3
|-
| FADD (additionneur flottant) || FMUL (multiplication flottante || FSTORE
|}
===Un aparté sur les instructions SIMD des processeurs x86===
L'extension flottante x87 est resté la norme pendant une bonne décennie, sur les CPU 32 bits. Mais par la suite, les choses ont changées avec l'introduction des '''instructions SIMD'''. Je ne vais pas rentrer dans le détail, un chapitre ultérieur détaillera les instructions SIMD. Mais je dois cependant parler des bases de ces instructions, car elles ont remplacé les instructions flottantes progressivement.
Mais avant toute chose, un petit historique,pour vous faire comprendre le vocabulaire utilisé dans ce qui suit. Les premières instructions SIMD ont été introduites en 1996, sur le Pentium MMX. Les instructions en question étaient regroupées dans une '''extension x86''' (un ajout au jeu d'instruction de base), appelé le MMX. Il ne contenait que des instructions SIMD pour des opérandes entiers. Mais par la suite, les extensions '''''Streaming SIMD Extensions''''' (SSE) ont ajouté des opérations flottantes. Les ''Streaming SIMD Extensions'', qu'on va abrévier SSE dans ce qui suit, ont été déclinées en plusieurs versions : SSE1, SSE2, SEE3, SSE4, et quelques intermédiaires. Puis, vint le '''AVX''' (''Advanced Vector eXtensions'').
Les instructions SIMD regroupent plusieurs opérations identiques en une seule instruction, la seule contrainte étant que les opérandes soient différentes. Par exemple, une instruction d'addition SIMD regroupe quatre additions séparées, chacun travaillant sur des opérandes différentes.
[[File:Instructions SIMD.png|centre|vignette|upright=3|Instructions SIMD]]
J'ai donné l'exemple d'une addition SIMD qui regroupe quatre additions flottantes, travaillant chacune sur ses opérandes rien qu'à elle. Les opérandes en question ne sont pas lues depuis les registres généraux ou flottants. A la place, les instructions utilisent un banc de registre dédié, qui contient des registres assez larges. Les instructions SIMD utilisent donc des '''registres SIMD''', qui mémorisent des paquets d'opérandes.
Par exemple, l'extension SSE a ajouté 16 registres XMM, de 128 bits chacun. un registre de 128 bits peut mémoriser : 2 entiers/flottants 64 bits, 4 entiers/flottants 32 bits, 8 entiers 16 bits, 16 entiers 8 bits.
[[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]]
L'AVX, quant à elle, donne accès à 16 registres de 256 bits chacun. Pour faire des économies de circuits, ils ont été fusionnés avec les registres XMM du SSE. Les 128 bits de poids faible de ces registres ne sont autre que les registres du SSE.
[[File:AVX registers.svg|centre|vignette|upright=1.5|Registres AVX.]]
Si je parle de ces instructions et registres SIMD, c'est parce qu'ils ont remplacé les instructions/registres flottants d'antan. Le x87 a disparu, les instructions SIMD font office d'instructions flottantes. Le remplacement a eu lieu avec le passage au 64 bits. Les raisons à cela sont multiples, mais la principale est que le x87 était très mal fait, avec une architecture obsolète. Une annexe en parle en détail, mais le x87 utilisait une pile de registre totalement inhabituelle, que les compilateurs ne pouvaient pas utiliser convenablement. Aussi, lors du passage au 64 bits, Intel et AMD en ont profité pour se débarrasser du x87 et le remplacer par quelque chose de plus moderne. Vu que les extensions SIMD faisaient déjà des calculs flottants, elles l'ont remplacé.
Et même dans le processeur, dans sa microarchitecture, le remplacement a été total. Le processeur intègre une ou plusieurs unités de calcul spécialisées dans les instructions SIMD. Et elles remplacent la FPU, totalement. Typiquement, il y a au minimum :
* une unité SIMD spécialisée dans les additions entières ;
* une unité SIMD spécialisée dans les multiplications entières ;
* une unité SIMD spécialisée dans les additions flottantes ;
* une unité SIMD spécialisée dans les multiplications flottantes.
* éventuellement une unité SIMD annexe pour les autres instructions SIMD, et elles sont nombreuses.
Les unités SIMD étaient parfois mutualisées avec la FPU, c'est à dire que la FPU x87 était en réalité une sous-partie de l'unité SIMD. Après tout, l'unité SIMD contient plusieurs additionneurs flottants 32/64 bits, idem pour les multiplieurs flottants. Alors autant en profiter pour leur faire faire des calculs flottants normaux, ceux de la FPU x87.
==Les processeurs x86 d'Intel==
Nous allons voir les processeurs Intel à part des processeurs AMD. La raison à cela est que les architectures Intel et AMD ont progressivement évolué, chacune se basant sur la précédente et l'améliorant. Il n'y a pas eu de cassure entre microarchitectures AMD, qui sont chacune la suite de la précédente. Il est donc préférable de voir les architectures AMD dans l'ordre chronologique. Par contre, Intel a eu une gigantesque cassure, avec le processeur Pentium 4. Son architecture se démarquait fortement du Pentium 3, mais elle n'a pas convaincu et a été abandonnée avec les processeurs suivants. Ce qui fait nous verrons l'architecture du Pentium 4 à part.
Nous allons commencer avec les processeurs Intel. N'y voyez pas du favoritisme derrière ce choix, la justification est toute autre. Si je commence par Intel, c'est pour commencer avec les Pentium 1 et 2, qui sont superscalaires, mais sans exécution dans le désordre. L'absence d'exécution dans le désordre les rend bien plus simples à étudier que les autres, ce qui en fait un bon point de départ pour ce chapitre. Les autres processeurs x86 sont tous avec à exécution dans le désordre, à une exception qu'on verra dans le chapitre suivant. AMD n'a pas produit de processeurs ''in-order'', tous les processeurs produits par AMD intègrent l'exécution dans le désordre.
Le Pentium 1 et 2 utilisaient la même architecture, qu'on détaillera dans ce qui suit. Les seules différences importantes étaient la fréquence, le cache, et quelques détails dans le genre. Le Pentium 3 était une nouvelle microarchitecture qui ajoutait l'exécution dans le désordre. Un an et demi plus tard, le Pentium 4 est sorti et a été un échec. Ses performances étaient peu convaincantes face au Pentium 3, et sa consommation énergétique était très importante. La conséquence est que le Pentium 4 et le Pentium 3 ont survécu pendant un long moment, beaucoup de monde préférait acheter un Pentium 3.
Intel a alors amélioré les microarchitectures du Pentium 3 et du 4, indépendamment, pendant environ 7 ans. La microarchitecture du Pentium 3 a subit plusieurs micro-évolutions, chacune avec une finesse de gravure différente, afin de satisfaire les consomateurs. Les premeirs Pentium 3 avaient une finesse de gravure de 250 nm, elle a chuté à 65 sur les derniers modèles.
L'architecture du Pentium 4 a fait la même chose, pour tenter de corriger ses problèmes de performance et de consommation d'énergie. Les premiers Pentium 4 avaient uen finesse de gravure de 180 nm, elle a elle aussi chutée à 65 sur les derniers modèles.
Après l'échec du Pentium 4, les ingénieurs d'Intel ont repris l'architecture P6 et l'ont améliorée fortement, pour donner l'architecture Core. Les micro-processeurs suivants ont fait évoluer cette architecture progressivement, au point où elle ne ressemble plus à l'originale. L'architecture Core a laissé la place à l'architecture Nehalem, puis Sandy Bridge, puis Haswell, puis Skylake, puis Ice Lake, et Golden Cove. Il s'agit de la lignée principale, partant du Pentium 3 et continuant jusqu'à nos jours. Ces microarchitectures ont suivi un motif assez simple, appelé modèle '''tick-tock'''. Chaque microarchitecture était déclinée en deux versions, la seconde ayant une finesse de gravure réduite.
En parallèle, Intel a travaillé sur des processeurs basse performance et basse consommation, avec une microarchitecture très différente. Les processeurs Atom de microarchitecture Bonnel, pour être ensuite remplacés par les microarchitectures Silvermont, puis Goldmont et Gracemont. Ces microarchitectures ont évolué en parallèle de la lignée principale, il s'agit d'une lignée secondaire. Le tout est résumé dans ce schéma ci-dessous.
[[File:IntelProcessorRoadmap-4v.svg|centre|vignette|upright=2.5|Roadmap des processeurs Intel, qui servira de structure pour la suite du chapitre]]
De nos jours, les processeurs Intel utilisent les deux types de micro-architectures en même temps. Les CPU Intel modernes disposent de deux types de coeurs : les coeurs P et le coeurs E. Leur nom signifie "Performance" et "Efficient", qui trahissent leur but. Les coeurs P utilisent la microarchitecture de la lignée principale, qui est conçue pour la performance. Les coeurs E, quant à eux, utilisent les micro-architectures basse consommation de la lignée secondaire.
Ceci étant dit, commençons par le commencement, avec le Pentium 1.
===Le Pentium 1/MMX : la double émission entière avec deux pipelines===
Le processeur Pentium d'Intel avait un pipeline de 5 étages : un étage de chargement/prédiction de branchement, deux étages de décodage, un étage d'exécution et un dernier étage pour l'écriture dans les registres. Le Pentium 1 était un processeur double émission, intégrant deux pipelines nommés U et V. Chose importante, les deux pipelines n'étaient pas identiques. Le pipeline U pouvait exécuter toutes les instructions, mais le pipeline V était beaucoup plus limité. Par exemple, seul le pipeline U peut faire des calculs flottants, le pipeline V ne fait que des calculs entiers et des branchements.
Les deux pipelines disposaient d'une unité de calcul entière, identique dans les deux pipelines. Mais le pipeline U incorporait un circuit multiplieur/diviseur et d'un ''barrel shifter''. L'unité flottante était sur le port d'émission du pipeline U, idem pour l'unité de calcul vectoriel MMX sur le Pentium MMX. Les deux pipelines avaient chacun une unité de calcul d'adresse, mais ils n'étaient pas identiques : celle du pipeline V ne gérait que l’instruction LEA, celle du pipeline U gérait tous les calculs d'adresse.
{|class="wikitable"
|-
! Pipeline U
! Pipeline V
|-
| ALU entière
| ALU entière
|-
| Multiplieur/diviseur
|
|-
| ''Barrel Shifter''
|
|-
| AGU complexe
| AGU simple (opération LEA)
|-
| FPU
|
|-
| Unité SIMD
|
|}
Les deux pipelines géraient les opérations bit à bit, les additions, les soustractions et les comparaisons. Les autres instructions ne sont exécutables que dans le pipeline U. Pour être plus précis, les deux pipelines supportaient les instructions suivantes, ce qui fait qu'on pouvait en faire deux en même temps :
* Les instructions arithmétiques INC, DEC, ADD, SUB ;
* l'instruction de comparaison CMP ;
* les instructions bit à bit AND, OR, XOR ;
* l'instruction de calcul d'adresse LEA ;
* l'instruction MOV (dépend du mode d'adressage) ;
* les instructions de gestion de la pile PUSH et POP (dépend du mode d'adressage) ;
* l'instruction NOP, qui ne fait rien.
Il faut noter qu'il y a cependant quelques restrictions, beaucoup de paires d'instructions sont interdites. La plupart interdisent au pipeline V de faire quoique ce soit quand une opération particulière est émise dans le pipeline U. Par exemple, si le pipeline U exécute une multiplication ou une division, le processeur ne peut pas exécuter une opération dans le pipeline V. Et c'est pareil avec les branchements : si un branchement est émis dans le pipeline U, l'instruction suivant le branchement n'est pas émise dans le pipeline V, pour éliminer les dépendances de contrôle. De même, si le pipeline U exécute une opération flottante, le pipeline V ne pourra rien exécuter. La seule exception est l'instruction FCXH, qui échange deux registres flottants.
[[File:Intel Pentium arch.svg|centre|vignette|upright=2.5|Microarchitecture de l'Intel Pentium MMX. On voit que certaines unités de calcul sont dupliquées.]]
Un choix assez intéressant a été fait pour le cache de données. Nous avions vu dans le chapitre sur les CPU superscalaires que la superscalarité a un impact sur l'unité mémoire. Il y a alors deux implémentations. La première ne fait rien, l'unité mémoire ne change pas, et le processeur ne peut pas faire deux accès mémoire simultanés. La seconde duplique l'unité mémoire et les ports de lecture/écriture du cache, ce qui autorise des accès mémoire simultanés. Le Pentium 1 utilise une solution intermédiaire.
Les ingénieurs d'Intel étaient partis à la base sur un cache totalement double port, pour obtenir des performances maximales. Mais diverses simulations et observations les ont fait changer d'avis. Les simulations ont montré qu'il est "rare" que les deux pipelines aient besoin de lire/écrire dans le cache en même temps. Et ils ont optimisé le cache de donnée pour en tenir compte. Le cache de données est partiellement multiport : simple port sur certains aspects, double port sur d'autres.
Le cache est un cache splité, à savoir que les données et les ''tags'' sont séparés dans des mémoires séparées. La mémoire pour les ''tags'' est multiport, ce qui permet d'interroger les ''tags'' du cache deux fois par cycle. Un port est relié au pipeline u, un autre au pipeline V. Mais pour les données, le cache n'a qu'un seul port pour lire/écrire des données. Impossible donc de lire deux données en même temps, pour alimenter les deux pipelines. Il utilise cependant 8 banques permet d'accélérer des accès mémoire proches dans le temps, mais dans des cycles d'horloge différents.
Entre les deux mémoires, il y a un circuit qui détecte les conflits, à savoir les situations où les deux pipelines accèdent en même temps au cache. S'ils veulent lire/écrire une donnée dans le même cycle, ce qui est impossible avec un seul port, le pipeline U a la priorité et le pipeline V attend le cycle suivant. Le circuit détecte aussi les conflits de banque, à savoir quand un pipeline accède à une banque en cours d'accès par l'autre pipeline (on rappelle qu'un accès au cache prend plusieurs cycles). Le circuit détecte aussi certaines dépendances mémoires, à savoir des accès consécutifs à la même adresse.
: La TLB du processeur est aussi totalement double port.
===La microarchitecture P6 (Pentium 3) : l'exécution dans le désordre===
Le Pentium 3 utilisait la '''microarchitecture P6''', qui a été dérivée dans de nombreuses variantes, dont les finesses de gravure n'étaient pas les mêmes. Il introduit une exécution dans le désordre simple, avec un ''scheduler'' centralisé, avec renommage dans le désordre dans le ROB (tampon de ré-ordonnancement), commandé par une table d'alias. C'était un processeur triple émission, soit une instruction de plus que la double émission du Pentium 1.
[[File:P6 func diag.png|centre|vignette|upright=2|P6 func diag]]
Le pipeline passe de 5 étage sur le Pentium à 14 - 12 étages, dont le détail est le suivant :
* Prédiction de branchement, deux cycles ;
* Chargement des instructions, trois cycles ;
* Décodage de l'instruction, deux cycles ;
* Renommage de registre, un cycle ;
* Copie des opérandes dans le tampon de ré-ordonnancement (lié au renommage de registre dans le ROB) ;
* Dispath dans ou depuis la station de réservation.
* Exécution de l'instruction ;
* Écriture du résultat dans le ROB ;
* Écriture dans le banc de registre physique.
Les instructions sont chargées par blocs de 16 octets, avec un système de fusion de blocs pour gérer les instructions à cheval sur deux blocs. Lors d'un branchement, deux blocs doivent être chargés si l'instruction de destination n'est pas alignée sur 16 octets et cela cause un délai de un cycle d'horloge.
Le décodage des instructions x86 était géré par plusieurs décodeurs. Il y avait trois décodeurs : deux décodeurs simples, et un décodeur complexe. Les décodeurs simples décodaient les instructions les plus fréquentes, mais aussi les plus simples, qui étaient décodées en une seule micro-opération. Les instructions CISC complexes étaient gérées uniquement par le décodeur complexe, basé sur un microcode, qui pouvait fournir jusqu'à 4 micro-opérations par cycle. Le tout est résumé avec la règle 4-1-1. La toute première instruction chargée depuis la file d'instruction va dans le premier décodeur simple. Si jamais le décodeur ne peut pas décoder l'instruction, l'instruction est redirigée dans un autre décodeur, avec un délai d'un cycle d'horloge.
Les stations de réservations étaient regroupées dans une structure centralisé, en sortie de l'unité de renommage. Elles avaient 4 ports d'émission. En réalité, 5 dont deux pour les écritures (un pour l'adresse à transmettre, un pour la donnée). Le tout avait une répartition moitié-moitié pour les calculs et les accès mémoire. Chaque port d'émission pour les calculs était relié à une ALU et une FPU/unité SIMD. Le tableau suivant résume tout.
Vous remarquerez la présence de plusieurs additionneurs flottants. Le premier exécute les opérations x87, l'autre les opérations SIMD. Les opérations SIMD se font sur des registres de 128 bits, mais elles sont en réalité cassées en deux µops, chacune travaillant sur 64 bits.
{|class="wikitable"
|+ Pentium 3
|-
! !! Port d'émission 1 !! Port d'émission 2 !! Port d'émission 3 !! Port d'émission 4 !! Port d'émission 5
|-
! rowspan="3" | µops entières
| ALU entière || ALU entière || LOAD || STORE adresse || STORE donnée
|-
| LEA || ALU SIMD MMX || || ||
|-
| Multiplieur entier || || || ||
|-
! rowspan="3" | µops flottantes
| x87 FADD || FADD 64 bits (SIMD) || ||
|-
| FMUL (x87 et 64 bits) || || ||
|-
| FDIV || || ||
|}
Les premiers Pentium 3 n'avaient pas de cache L2 dans le processeur, celui-ci était sur la carte mère. Mais il a été intégré dans le processeur sur la seconde version du Pentium 3, la version Coppermine.
Le Pentium 3 a servi de base aux microarchitectures d'Intel qui ont suivi. Les changements à chaque nouvelle génération sont assez mineurs : la prédiction de branchement est améliorée, la taille des stations de réservation et du ROB augmente, idem avec les autres structures liées à l'exécution dans le désordre. Les processeurs Intel ont conservé un ''scheduler'' centralisé, alors qu'AMD utilise une autre méthode, comme nous allons le voir dans ce qui suit. Les seuls changements notables sont est le passage à un renommage dans le ROB à un renommage à banc de registre physique, ainsi que l'introduction du cache de micro-opération. Et ce sont des modifications qu'AMD a aussi faites, celle-ci étant clairement une bonne idée pour toutes les micro-architectures avec un budget en transistor suffisant. Il est intéressant de garder cela en tête, car une bonne partie des améliorations de chaque micro-architecture proviendra de là.
===La microarchitecture Core : désambiguïsation mémoire, macro-fusion, ''stack engine'', ''Fetch Input Queue''===
La '''microarchitecture Core''' fait suite au Pentium 4, mais reprend en fait beaucoup d’éléments du Pentium 2 et 3. Elle utilise la station de réservation unique avec renommage dans le ROB, provenant du Pentium 2/3. A part ça, les améliorations sont assez diverses : un cache L2, l'arrivé de la désambiguïsation mémoire, une ''Fetch Input Queue'' et un ''stack engine'' primitif. Le processeur introduit aussi la macro-fusion qui fusionne une instruction de test et le branchement qui suit en une seule micro-opération. beaucoup d'optimisations importantes, donc.
[[Image:Intel Core2 arch.svg|centre|vignette|upright=2|Intel Core microarchitecture]]
Il y a quelques modifications au niveau de l'unité de chargement. La file d'instruction a toujours ce système de fusion de blocs, sauf que les branchements ne causent plus de délai d'un cycle lors du chargement. La file d'instruction est suivie par un circuit de prédécodage qui détermine la taille des instructions et leurs frontières, avant de mémoriser le tout dans une file de 40 instructions.
La station de réservation dispose de 5 ports d'émission, dont un pour les lectures et un pour les écritures. Les deux sont reliées à une ''Load/Store Queue'', appelée ''Memory Ordering Buffer'', elle-même reliée au cache de données par un port de lecture et un port d'écriture. Les ports de lecture/écriture du cache font 128 bits, pour gérer naturellement les instructions LOAD/STORE dans les registres SIMD. Les trois ports restants sont chacun reliés à des ALU entières et des unités flottantes/SIMD (pour l'extension SSE). Le multiplieur entier est relié au second port d'émission. L'additionneur flottant SIMD/SSE est connecté au port 1, le multiplieur/diviseur flottant SIMD/SSE est connecté au port 0, une unité SIMD annexe est connectée sur chaque port.
{|class="wikitable"
|+ Microarchitecture Core (Penryn et Merom)
|-
! !! Port d'émission 1 !! Port d'émission 2 !! Port d'émission 3 !! Port d'émission 4 !! Port d'émission 5
|-
! rowspan="3 | µops entières/mémoire
| ALU entière || ALU entière || ALU entière || LOAD || STORE
|-
| ''Barrel shifter'' || Multiplieur entier || ''Barrel shifter'' || ||
|-
|-
| || || ALU branchements || ||
|-
! rowspan="3 | µops flottantes et SIMD
| FMUL et FDIV || FADD || ||
|-
| ALU SIMD entière (128 bits) || ALU SIMD entière (128 bits) || ALU SIMD entière (128 bits) || ||
|-
| || MUL SIMD entière (128 bits) || || ||
|}
Le résultat que le processeur peut émettre un mix d'opérations flottantes et entière assez varié, avec un mix allant de trois opérations entières à trois opérations flottantes, avec tous les intermédiaires. Le fait de mettre les ALU SIMD sur des ports différents permet d'émettre une addition et une multiplication flottante/SIMD simultanément.
===La microarchitecture Nehalem : un vrai ''Loop Stream Detector''===
Les microarchitectures '''Nehalem''' et '''Westmere''' n'introduisent pas d'optimisation majeure. A la rigueur, ils introduisent une prédiction de branchement à deux niveaux, et une macro-fusion plus complète. Les unités de calcul sont assez semblables, de même que leur répartition sur les ports d'émission.
Elle a introduit cependant un vrai ''Loop Stream Detector'' (LSD). Pour rappel, le LSD qui permet d'exécuter les boucles directement depuis la file de µops. Le LSD reconnait les petites boucles, qui tiennent dans la file de µops, et lit les µops adéquates depuis celle-ci. Les instructions de la boucle n'ont donc pas à être chargée et décodées.
La microarchitecture Core avait une sorte de pseudo-''Loop Stream Detector'', mais il était localisé dans la file d'instruction. Il était donc moins performant. Ce pseudo-''Loop Stream Detector'' permettait de ne pas charger les instruction x86, mais elles devaient quand même être décodées et passer par la file de µops. Avec Nehalem, ce n'est plus le cas.
[[File:Intel Nehalem arch.svg|centre|vignette|upright=2.5|Intel Nehalem.]]
===La microarchitecture Sandy Bridge : le cache de µops===
Les microarchitectures '''Sandy Bridge''' and '''Ivy Bridge''' ont introduit le renommage à banc de registre physique et l'ajout d'un cache de micro-opérations. L'ajout du cache de micro-opérations est une optimisation particulièrement rentable, particulièrement pour le complexe jeu d’instruction x86. Avec ce cache, les décodeurs servent surtout à alimenter le cache de micro-opérations, parfois décoder des instructions en-dehors de boucles, pas plus. Intel a d'ailleurs reflété ce fait dans sa terminologie. Intel distingue deux voies de chargement : le ''legacy pipeline'' et le cache de micro-opérations. L'unité de chargement et les décodeurs sont regroupés dans la voie du ''legacy pipeline''.
Sur les processeurs modernes, les décodeurs ne servent que pour 10 à 20% des micro-opérations exécutées, le reste est lu depuis le cache de µops. Cependant, Sandy Bridge a un cache de µops assez limité, qui ne permet pas d’atteindre ces chiffres. De nombreuses instructions ne peuvent pas être mémorisées dans le cache de µops, les limitations quand à son utilisation sont assez importantes. Par exemple, le processeur ne consulte pas le cache de µops à chaque cycle, il ne démarre son utilisation qu'après un branchement (donc une boucle potentielle) et cesse de l'utiliser au premier défaut de cache (dans le cache de µops).
Le cache de micro-opérations est complété avec un ''Loop Stream Detector'', placé après le cache en question. Les décodeurs et le cache de micro-opérations alimentent une file de micro-opérations, située juste avant l'étage de renommage de registres. La file de micro-opérations sert en quelque sorte de tampon entre l'étage de "décodage" et celui de renommage. Le ''Loop Stream Detector'' utilise cette file de micro-opérations comme d'un cache lorsqu'une boucle est détectée. Les micro-opérations de la boucle sont lue depuis la file de micro-opérations, pour être envoyée au renommeur de registres.
L'avantage est que le cache de micro-opérations et/ou les décodeurs sont mis en pause et clock-gatés lorsqu'une boucle s'exécute, ce qui réduit la consommation du processeur. Le ''Loop Stream Detector'' et le cache de micro-opération ont globalement le même effet : désactiver tout ce qui est avant, le ''Loop Stream Detector'' appliquant cette méthode au cache de micro-opération lui-même.
Pour le reste, ''Sandy Bridge'' n'innove pas trop. Les unités d'exécution sont globalement les mêmes, leur répartition sur les port est peu changée. LA seule différence intéressante est que ''Sandy Bridge'' n'a plus une unité mémoire dédiée aux LOAD et une aux STORE. Elle en a deux qui peuvent faire indifféremment LOAD et STORE. Le processeur peut donc émettre deux LOAD simultanément, ce qui entraine un gain en performance appréciable.
===Les microarchitectures Haswell et Broadwell===
Les architectures '''Haswell''' et '''Broadwell''' ont ajouté quelques unités de calcul, élargit la sortie du cache de micro-opérations. Un port d'émission pour opération entières a été ajouté, de même qu'un port pour les accès mémoire. Le processeur passe donc à 8 ports d'émission, ce qui permet d'émettre jusqu'à 8 micro-opérations, à condition que le cache de micro-opération suive. Pour le reste, le processeur est similaire aux architectures précédentes, si ce n'est que certaines structures grossissent.
{|class="wikitable"
|+ Microarchitecture Haswell et Broadwell
|-
! !! Port d'émission 1 !! Port d'émission 2 !! Port d'émission 3 !! Port d'émission 4 !! Port d'émission 5 !! Port d'émission 6
|-
! rowspan="3" | µops entières/mémoire
| ALU entière || ALU entière || ALU entière || ALU entière || LOAD/STORE || LOAD/STORE
|-
| ''Barrel shifter'' || Manipulation de bits || ''Barrel shifter'' || Manipulation de bits || ||
|-
| || Multiplieur entier || ALU branchements || || ||
|-
! rowspan="4" | µops flottantes et SIMD
| SIMD bit à bit (256 bits) || SIMD bit à bit (256 bits) || SIMD bit à bit (256 bits) || || ||
|-
| || ALU SIMD entière (256 bits) || ALU SIMD entière (256 bits) || || ||
|-
| FMUL + FMA (256 bits) || FMUL + FMA (256 bits) || || || ||
|-
| FDIV || FADD || || || ||
|}
===La microarchitecture Skylake : la fin de la station de réservation centralisé===
L'architecture '''Skylake''' a eu une longévité hors du commun, avec près de 6 ans d’utilisation ininterrompue. Elle a été la concurrentes des microarchitectures Zen 1, Zen 2 et Zen 3 d'AMD. La microarchitecture ''Skylake'' est une microarchitecture capable de décoder/renommer 4 instructions par cycle, elle peut émettre 6 µops par cycle grâce au cache de µops. Elle a été déclinée en plusieurs microarchitectures légèrement différentes les unes des autres, nommés '''''Kaby Lake''''', '''''Coffee Lake''''', '''''Comet Lake'''''.
Un changement extrêmement important concerne la station de réservation centralisé, qui ne l'est plus sur Skylake. Elle est scindée en deux stations de réservations : une pour les µops mémoire, et une autre pour le reste. Mien de rien, c'est à ce moment que la tendance "Intel = station de réservation centralisé " a commencée à s'adoucir. Intel est longtemps resté sur cette organisation avec un '''''Math Scheduler''''' d'un côté, et un '''''MEM scheduler''''' de l'autre.
Pour le reste, les améliorations sont mineures. A la rigueur, l'unité de renommage de registre ajoute des optimisations comme l'élimination des MOV, les idiomes liés aux opérations avec zéro, etc.
===La microarchitecture ''Sunny Cove'' : la station de réservation mémoire est scindée===
Après Skylake, les microarchitecture d'Intel ont changé de dénomination. Elles sont maintenant nommées non pas avec des noms de lacs, mais ont un nom qui finit par Cove : ''Sunny Cove'', ''Golden Cove'', etc. La seule exception à cette règle est la microarchitecture '''''Palm Cove''''' était un ''die shrink'' de Skylake, ce qui fait qu'elle est à part de celles qui vont suivre, qui sont vraiment des microarchitectures différentes.
La microarchitecture qui suit immédiatement skylake était la microarchitecture '''''Sunny Cove'''''. La microarchitecture''Sunny Cove'' suit la tendance entamée avec Skylake de casser la station de réservation principale en plusieurs stations de réservations plus petites. Cette fois-ci c'est la station de réservation pour les µops mémoire qui est scindée en deux une pour les lectures et une pour les écritures.
La microarchitecture ''Sunny Cove'' passe de quadruple émission à la pentuple émission. Précisément, l'unité de renommage de registres est maintenant capable de renommer 5 µops par cycle. De plus, l'unité de renommage devient plus performante. Ses capacités de ''MOV elimination'' ont été améliorées, et elle devient capable de reconnaitre certains idiomes, comme celui qui met à zéro un registre en le XORant avec lui-même.
''Sunny Cove'' était au départ prévue pour les PC ''desktop'', mais sa production a eu de nombreux problèmes. Elle était censée être produite avec une technologie de gravure 10nm d'Intel lui-même, qui a cependant eu de nombreux problèmes de production. Au final, ''Sunny Cove'' et ses variantes n'ont existées que dans des CPU mobile, basse performance et basse consommation. Elle a été déclinée en deux versions : '''''Willow Cove''''' a un cache retravaillé, alors que '''''Cypress Cove''''' est l'équivalent de ''Sunny Cove'' mais repassé à une technologie de gravure de 14nm. Les deux microarchitecture ont été utilisées sur les plateformes dites '''''Ice Lake''''' et '''''Tiger Lake'''''.
===La microarchitecture ''Golden Cove'' : un ''Fetch'' doublé===
La microarchitecture '''Golden Cove''' a suivi ''Sunny Cove''. Les microarchitectures suivantes, ''Redwood Cove'' et ''Raptor Cove'', sont des modifications mineures de ''Golden Cove''.
''Golden Cove'' altère les décodeurs et l'unité de chargement. Sur toutes les générations précédentes, on reste sur une unité de chargement qui charge 16 octets à la fois. Une telle stagnation sur les unités de chargement et de décodage s'explique encore une fois par la présence du cache de micro-opération fait que ce n'est pas trop un problème. Tout ce qui précède le cache de micro-opérations n'a pas de raison d'évoluer, car ce cache est très puissant. Mais cela change avec ''Golden Cove''.
Golden Cove double la taille du chargement qui passe à 32 octets. Le processeur peut décoder 6 instructions par cycle, et en émettre 6 à destination des ''schedulers''s. Il s'agit donc de processeurs superscalaires très larges, ce qui est la norme de nos jours et le restera pendant longtemps. ''Golden Cove'' conserve les trois ''schedulers'' séparées de ''Sunny Cove'', savoir : pour les instructions entières/flottantes, pour les lectures, pour les écritures.
[[File:Golden Cove.png|centre|vignette|upright=3|Golden Cove]]
Niveau unités de calcul, peu de changements avec ''Sunny Cove'', si ce n'est une ALU entière en plus.
{|class="wikitable"
|-
! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5
|-
| ALU entière || ALU entière || ALU entière || ALU entière || ALU entière
|-
| ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' ||
|-
| Branchements || Diviseur || || Branchements ||
|}
===La microarchitecture ''Lion Cove'' : la scission du ''math scheduler''===
La microarchitecture '''''Lion Cove''''' poursuit la tendance à scinder la station de réservation. La station de réservation pour les opérations entières/flottantes est maintenant scindée en deux : une pour les µops entières, une autre pour les µops SIMD/flottantes. Une autre modification, très liée à la précédente, est que le renommage de registre est lui aussi scindé. Les registres SIMD/flottants sont maintenant renommé indépendamment des registres généraux/entiers.
Les raisons sont certainement que cela simplifie grandement la conception du processeur, en séparant les pipelines entiers et SIMD, surtout que les opérations entières et SIMD sont forts différentes : instructions 2-opérandes d'un côté, 2 à 3-opérandes pour le SIMD ; présence de techniques de ''masking'' pour le SIMD, mais pas pour les opérations entières, etc.
Pour le reste, le processeur de 6 à 8 voies d'émission, et l'unité de chargement peut maintenant charger 48 octets, au lieu de 32 auparavant. Des ALU entières et SIMD ont été rajoutés. L'unité d'émission pour les opérations entières dispose de 6 ports d'émission, alors que celle pour les opérations SIMD n'en a que 4.
{|class="wikitable"
|+ Microarchitecture Haswell et Broadwell
|-
! !! Port d'émission 1 !! Port d'émission 2 !! Port d'émission 3 !! Port d'émission 4 !! Port d'émission 5 !! Port d'émission 6
|-
! rowspan="3" | µops entières/mémoire
| ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || ALU entière
|-
| Branchements || ''Barrel shifter'' || Branchements || ''Barrel shifter'' || Branchements || ''Barrel shifter''
|-
| || Multiplieur entier || || Multiplieur entier || || Multiplieur entier
|}
==Les processeurs x86 d'AMD==
Les architectures Intel ont évolué progressivement, sans grandes cassure. Il y a une continuité presque ininterrompue entre l'architecture du Pentium 2 et les architectures modernes. Intel a fait des améliorations mineures à chaque nouvelle micro-architecture, si on omet le passage à un renommage à banc de registre physique et l'ajout du cache de micro-opération. A l'opposé, les architectures AMD ont eu de nombreuses cassures dans la continuité où AMD a revu sa copie de fond en comble.
Étudier ces architectures demande de voir trois choses séparément : le ''front-end'' qui regroupe l'unité de chargement et les décodeurs, le ''back-end'' qui gère l'exécution dans le désordre et les unités de calcul, et le sous-système mémoire avec les caches et la ''Load Store Queue''. Leur étude sera plus ou moins séparée dans ce qui suit, pour chaque classe d'architecture.
===La première génération de CPU AMD : les architectures K5, K6, K7, K8 et K10===
La première génération de processeurs AMD est celle des architectures K5, K6, K7, K8 et K10. Il n'y a pas de K9, qui a été abandonné en cours de développement. Les processeurs K5 et K6 portent ce nom au niveau commercial. Par contre, les processeurs d'architecture K7 sont aussi connus sous le nom d''''AMD Athlon''', les AMD K8 sont connus sous le nom d''''AMD Athlon 64''', et les architecture K10 sont appelées les '''AMD Phenom'''. Comme le nom l'indique, l'architecture K8 a introduit le 64 bits chez les processeurs AMD.
Elles ont une architecture assez similaire pour ce qui est du chargement et des caches. Toutes disposent d'au minimum un cache L1 d'instruction et d'un cache L1 de données. Le K5 n'avait que ces caches, mais un cache L2 a été ajouté avec le K7, puis un L3 avec le K10. L'AMD K5 avait une TLB unique, mais les processeurs suivants avaient une TLB pour le L1 d'instruction et une autre pour le L1 de données. Idem pour le cache L2, avec deux TLB : une pour les données, une pour les instructions.
Les caches L1/L2 sont de type exclusifs, à savoir que les données dans le L1 ne sont pas recopiées dans le L2. Le cache L2 est précisément un cache de victime, qui mémorise les données/instructions, évincées des caches L1 lors du remplacement des lignes de cache. L'introduction du cache L2 a entrainé l'ajout de deux TLB de second niveau : une L2 TLB pour les données et une autre pour les instructions. Les architectures K8 et K10 ont ajouté un cache L3, avec un accès indirect à travers l'interface avec le bus.
: L'AMD K7 originel, aussi appelée Athlon classique, n'avait pas de cache L2, mais celui-ci était placé sur la carte mère et fonctionnait à une fréquence moitié moindre de celle du CPU. L'Athlon Thunderbird, puis l'Athlon XP, ont intégré le cache L2 dans le processeur.
{|class="wikitable"
|-
! Architecture AMD
! colspan="5" | Caches
|-
| rowspan="2" | K5
| L1 instruction || L1 données || colspan="3" |
|-
| colspan="2" | TLB unique || colspan="3" |
|-
| colspan="4" |
|-
| rowspan="2" | K6
| L1 instruction || L1 données || colspan="3" | L2 unifié
|-
| TLB L1 instruction || TLB L1 données || colspan="3" |
|-
| colspan="6" |
|-
| rowspan="2" | K7, K8
| L1 instruction || L1 données || colspan="2" | L2 unifié ||
|-
| TLB L1 instruction || TLB L1 données || TLB L2 instruction || TLB L2 données ||
|-
| colspan="6" |
|-
| rowspan="2" | K10
| L1 instruction || L1 données || colspan="2" | L2 unifié || L3
|-
| TLB L1 instruction || TLB L1 données || TLB L2 instruction || TLB L2 données ||
|}
Fait important, les architectures K5 à K10 utilisent la technique du '''prédécodage''', où les instructions sont partiellement décodées avant d'entrer dans le cache d'instruction. Le prédécodage facilite grandement le travail des décodeurs d'instruction proprement dit. Par contre, le prédécodage prend de la place dans le cache L1 d'instruction, une partie de sa capacité est utilisé pour mémoriser les informations prédécodées. C'est donc un compromis entre taille du cache et taille/rapidité des décodeurs d'instruction.
Sur les architectures K5 et K6, le prédécodage précise, pour chaque octet, si c'est le début ou la fin d'une instruction, si c'est un octet d'opcode, en combien de micro-opérations sera décodée l'instruction, etc. A partir de l'AMD K7, le prédécodage reconnait les branchements inconditionnels. Lorsqu'un branchement inconditionnel est pré-décodé, le pré-décodage tient compte du branchement et continue le pré-décodage des instructions à partir de la destination du branchement. Le système de prédécodage est abandonnée à partir de l'architecture Bulldozer, qui suit l'architecture K10.
La prédiction de branchement de ces CPU tire partie de ce système de pré-décodage, à savoir que les prédictions de branchement sont partiellement mémorisées dans les lignes de cache du L1 d'instruction. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela. 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. 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.
Un défaut de cette approche est que si le branchement 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 un branchement qui a été évincé dans le L2 ou le L3, tant que l'entrée associée est dans le BTB. Les prédictions peuvent même servir à précharger les instructions utiles.
[[File:Comparaison du chargement de l'AMD K5 et K6.png|centre|vignette|upright=2|Comparaison du chargement de l'AMD K5 et K6]]
Au niveau du décodage, on trouve de nombreuses différences entre les premières architectures AMD. L'AMD K5 contient 4 décodeurs hybrides, afin de décoder 4 instructions par cycles. Le K5 a quatre décodeurs simples couplés à 4 décodeurs complexes avec chacun un accès au micro-code. Une instruction peut donc passer par a donc deux voies de décodage : un décodage rapide et simple pour les instructions simples, un décodage lent et passant par le microcode pour les instructions complexes. Pour décoder 4 instructions, les deux voies sont dupliquées en 4 exemplaires, ce qui a un cout en circuits non-négligeable.
L'AMD K6 utilise moins de décodeurs et ne peut que décoder deux instructions à la fois maximum. Par contre, il fournit en sortie 4 micro-opérations. Il intègre pour cela deux décodeurs simples, un décodeur complexe et un décodeur micro-codé. Un décodeur simple transforme une instruction simple en une ou deux micro-opérations. Il est possible d'utiliser les deux décodeurs simples en même temps, afin de fournir 4 micro-opérations en sortie du décodeur. Les deux autres décodent une instruction complexe en 1 à 4 micro-opérations. Si jamais la ou les deux instructions sont décodées en 1, 2 ou 3 micro-opérations, les micro-opérations manquantes pour atteindre 4 sont remplies par des NOPs.
Pour le K7 et au-delà, le processeur dispose de décodeurs séparées pour les instructions micro-codées de celles qui ne le sont pas. Le processeur peut décoder jusqu’à 3 instructions par cycle. Le décodage d'une instruction microcodée ne peut pas se faire en parallèle du décodage non-microcodé. C'est soit le décodeur microcodé qui est utilisé, soit les décodeurs câblés, pas les deux en même temps. Le décodage d'une instruction prend 4 cycles. Les instructions non-microcodées sont décodées en une seule micro-opération, à un détail près : le CPU optimise la prise en charge des instructions ''load-up''.
La différence entre le K6 et le K7 s'explique par des optimisations des instructions ''load-up''. Sur le K6, les instructions ''load-up'' sont décodées en deux micro-opération : la lecture en RAM, l'opération proprement dite. Mais sur le K7, une instruction ''load-up'' est décodée en une seule micro-opération. En conséquence, les décodeurs simples sont fortement simplifiés et le décodeur complexe disparait au profit d'un microcode unique.
[[File:Décodage sur le K5 et le K5.png|centre|vignette|upright=3|Décodage sur le K5 et le K5]]
====Les micro-architectures K5 et K6 d'AMD====
Les deux premières architectures étaient les architectures K5 et K6, l'architecture K6 ayant été déclinée en quatre versions, nommées K6-1, K6-2, et K-3, avec une version K6-3 bis. Elles sont regroupées ensemble car elles ont beaucoup de points communs. Par exemple, tout ce qui a trait au chargement et au cache était similaire, de même que les unités de calcul.
Les deux architectures avaient n'avaient pas de cache L2 et devaient se contenter d'un cache L1 d'instruction et d'un cache L1 de données. L'AMD K5 incorpore une TLB unique, alors que le K6 utilise des TLB séparées pour le cache d'instruction et le cache de données. Une différence entre l'architecture K5 et K6 est que la première utilise des caches normaux, alors que la seconde utilise des ''sector caches''.
Les deux architectures disposaient des unités de calcul suivantes : deux ALU entières, une FPU, deux unités LOAD/STORE pour les accès mémoire, une unité de branchement et une ou plusieurs unités SIMD. Une organisation classique, donc.
Pour les unités entières, il y avait deux ALU simples, un ''barrel shifter'' et un diviseur. Il n'y a pas d'erreur, le processeur incorpore un circuit diviseur, mais pas de circuit multiplieur. La raison est que la multiplication est réalisée par la FPU ! En effet, le multiplieur flottant de la FPU intègre un multiplieur entier pour multiplier les mantisses, qui est utilisé pour les multiplications entières. La même technique a été utilisée sur l'Atom, comme vu plus haut. Le tout était alimenté par deux ports d'émission, appelés ports X et Y. Sur l'architecture K5, le ''barrel shifter'' et le diviseur sont des ports différents.
{|class="wikitable"
|+ AMD K5
|-
! Port X
! Port Y
|-
| ALU simple
| ALU simple
|-
| ''Barrel Shifter''
| Diviseur
|}
Sur l'architecture K6, le ''barrel shifter'' et le diviseur sont sur le même port.
{|class="wikitable"
|+ AMD K6
|-
! Port X
! Port Y
|-
| ALU simple
| ALU simple
|-
|
| ''Barrel Shifter''
|-
|
| Diviseur
|}
Niveau unités mémoire, le K5 avait deux unités LOAD/STORE, chacune capable de faire lecture et écriture. Par contre, la ''store queue'' n'a qu'un seul port d'entrée, ce qui fait que le processeur peut seulement accepter une écriture par cycle. Le processeur peut donc émettre soit deux lectures simultanées, soit une lecture accompagnée d'une écriture. Impossible d'émettre deux écritures simultanées, ce qui est de toute façon très rare. L'architecture K6 utilise quant à elle une unité LOAD pour les lectures et une unité STORE pour les écritures. Ce qui permet de faire une lecture et une écriture par cycle, pas autre chose.
Niveau unités SIMD, l'architecture K7 n'avait qu'une seule unité SIMD, placée sur le port d'émission X. L'architecture K8 ajouta une seconde unité SIMD, sur l'autre port d'émission entier. De plus, trois ALU SIMD ont été ajoutées : un décaleur MMX, une unité 3DNow!, une unité mixte MMX/3DNow. Elles sont reliées aux deux ports d'émission entier X et Y ! Elles ne sont pas représentées ci-dessous, par souci de simplicité.
[[File:Unité de calcul des processeurs AMD K5 et K6.png|centre|vignette|upright=2|Unité de calcul des processeurs AMD K5 et K6. les unités sur la même colonnes sont reliées au même port d'émission.]]
Si les unités de calcul et le chargement sont globalement les mêmes, les deux architectures se différencient sur l'exécution dans le désordre. L'AMD K5 utilise du renommage de registre dans le ROB avec des stations de réservation. Par contre, l'AMD K6 utilise un ''scheduler'' centralisé. De plus, son renommage de registre se fait avec un banc de registre physique.
L'architecture AMD K5 utilisait de deux stations de réservation par unité de calcul, sauf pour les deux unités mémoire partageaient une station de réservation unique (deux fois plus grande). Les stations de réservation sont cependant mal nommées, vu que ce sont en réalité des mémoire FIFO. Une micro-opération n'est émise que si elle est la plus ancienne dans la FIFO/station de réservation. Le renommage de registres se faisait dans le tampon de ré-ordonnancement, il n'y avait pas encore de banc de registre physique. Le tampon de ré-ordonnancement faisait seulement 16 instructions.
[[File:AMD K5.jpg|centre|vignette|upright=3|AMDK5 Diagramme.]]
L'architecture K6 remplace les stations de réservations par un ''scheduler'' centralisé. Les 4 micro-opérations renommées sont écrites dans le ''scheduler'' par groupe de 4, NOP de ''padding'' inclus. le ''scheduler'' centralisé contient 24 micro-opérations, groupées en 6 groupes de 4 micro-opérations, avec potentiellement des NOP dedans suivant le résultat du décodage. L'avantage est que l'implémentation de le ''scheduler'' est simple. le ''scheduler'' centralisé permettait d'émettre 6 micro-opérations en même temps (une par unité de calcul/mémoire). Le renommage de registres se faisait dans le tampon de ré-ordonnancement, il n'y avait pas encore de banc de registre physique.
Le processeur utilisait un renommage avec un banc de registre physique. Le banc de registre physique pour les entiers contenait 48 registres, dont 24 étaient des registres architecturaux et 24 étaient des registres renommés. Sur les 24 registres architecturaux, 16 avaient une fonction de ''scratchpad'' que les ''datasheets'' d'AMD ne détaillent pas, les 8 restants étaient les registres généraux EAX, EBX, etc.
[[File:AMD K6 Little foot & Modl 6.png|centre|vignette|upright=3|AMD K6 original.]]
====Les micro-architectures K7, K8 et K10 d'AMD====
Les micro-architectures suivantes sont les architectures K7, K8 et K10. Les architectures K7, K8 et K10 sont assez similaires. La différence principale entre le K7 et le K8 est le support du 64 bits. Les apports du K10 sont la présence d'un cache L3, d'une unité de calcul supplémentaire et d'améliorations de la prédiction de branchement. La taille de certains caches a été augmentée, de même que la largeur de certaines interconnexions/bus.
L'architecture K7 des processeurs Athlon utilisait le renommage de registre, mais seulement pour les registres flottants, pas pour les registres entiers. Le ranommeg des registres flottants étaient réalisé via un banc de registres physique, ne contenant que des registres flottants. Les architectures K8 et K10 utilisent le renommage de registres pour tous les registres, entiers comme flottants. Par contre, le renommage de registre n'est pas réalisé de la même manière pour les registres entiers et flottants. Les registres entiers sont renommés dans le tampon de ré-ordonnancement, comme c'était le cas sur les architectures Intel avant le Pentium 4. Par contre, les registres flottants sont renommés grâce à un banc de registre physique. Le K8 est donc un processeur au renommage hybride, qui utilise les deux solutions de renommage principales.
A partir du K7, le CPU optimise la prise en charge des instructions ''load-up''. Les instructions ''load-op'' sont appelées des macro-opérations dans la terminologie d'AMD, et aussi d'Intel. L'idée est que les instructions ''load-up'' sont décodées en micro-opérations intermédiaires. Elles sont propagées dans le pipeline comme étant une seule micro-opération, jusqu'à l'étage d'émission. Lors de l'émission, les instructions ''load-up'' sont scindées en deux micro-opérations : la lecture de l'opérande, puis l'opération proprement dite. Faire ainsi économise des ressources et optimise le remplissage du tampon de ré-ordonnancement, des ''schedulers''s, des stations de réservation, etc.
Le tampon de réordonnancement est combiné avec divers circuits en charge de l'exécution dans le désordre, dans ce qui s'appelle l'''instruction control unit''. Il contient de 72 à, 84 instructions, qui sont regroupées en groupes de 3. Là encore, comme pour le K5 et le K6, le tampon de réordonnancement tient compte de la sortie des décodeurs. Les décodeurs fournissent toujours trois micro-opérations par cycle, quitte à remplir les vides par des NOP. Le tampon de réordonnancement reçoit les micro-opérations, NOP inclus, par groupes de 3, et est structuré autour de ces triplets de micro-opération, y compris en interne.
Pour ce qui est de l'unité mémoire, elle est précédée par une LSQ, qui émet les accès mémoire dans l'ordre du programme. Elle est souvent qualifiée de ''Load-Store Queue'', mais ce n'est pas la terminologie que nous utilisons dans ce cours. La file de micro-opération lire/écrire 64 bits par cycle depuis le cache L1, ce qui fait un seul accès au cache par cycle. La LSQ est appelée la ''Pre-Cache Queue''. Si au vu de son nom, vous avez deviné qu'il y avait une ''Post-Cache Queue''. Elle mémorise les lectures/écritures émises, mais qui ont levé un défaut de cache L1. Elle ne fait pas partie de la LSQ proprement dite.
Les architectures K7, K8 et K10 ont des unités de calcul très similaires. Concrètement, il y a trois ALU entières, trois unités de calcul d'adresse, et une FPU. Le processeur incorpore, aussi un multiplieur entier, relié sur le port d'émission de la première ALU. La FPU regroupe un additionneur flottant, un multiplieur flottant, et une troisième unité LOAD/STORE pour les lectures/écritures pour les nombres flottants. L'architecture K8 ajoute une unité de manipulation de bit, la K10 un diviseur entier.
[[File:Unité de calcul des processeurs AMD K7, K8 et K10.png|centre|vignette|upright=2|Unité de calcul des processeurs AMD K7, K8 et K10]]
La manière d'alimenter les ALU en micro-opérations varie un petit peu entre les architectures K7, K8 et K10. Il y a cependant quelques constantes entre les trois. La première est qu'il y a un ''scheduler'' séparée pour les flottants, de 36 à 42 entrées, avec renommage de registre. le ''scheduler'' flottante a trois ports d'émission : un pour l'additionneur flottant, un autre pour le multiplieur, et un troisième pour la troisième unité flottante qui s'occupe du reste. La seconde est que chaque ALU entière est couplée avec une unité de calcul d'adresse. Par contre, la méthode de couplage varie d'un processeur à l'autre.
: Les stations de réservation sont nommées des ''schedulers'' dans les schémas qui suivent.
La micro-architecture K7 avait deux ''schedulers'' : une pour les opérations flottantes, une autre pour les instructions entières et les accès mémoire. le ''scheduler'' entière était reliée à 3 ALU entières et à 3 AGU. Elle pouvait émettre trois micro-opérations en même temps : trois micro-opérations entières, trois micro-opérations mémoire. Les AGU étaient reliées à la LSQ mentionnée plus haut, ce qui permet d'émettre trois µops mémoire par cycle. Par contre, la LSQ ne pouvait exécuter qu'une lecture de 64 bits ou une écriture de 64 bits. En clair, trois micro-opérations mémoire peuvent être émises par cycle, cela entraine trois calculs d'adresse simultanés, mais les trois lectures/écritures sont mises en attente dans la LSQ. Elles s'exécutent alors l'une après l'autre.
le ''scheduler'' entière contenait 5 à 6 groupes de 3 macro-opérations. Vous noterez que j'ai parlé de macro-opérations et pas de micro-opérations, car les instructions ''load-up'' sont considérées comme une seule "micro-opération" dans le ''scheduler'' entière. Et cela se marie bien avec un ''scheduler'' unique partagée entre pipeline entier et pipeline mémoire. Une macro-opération était scindée en deux micro-opérations : une micro-opération mémoire et une micro-opération entière. Il est donc avantageux de regrouper unités mémoire et unités entières au même ''scheduler'' pour ce faire.
[[File:AMD K7.png|centre|vignette|upright=3|AMD K7]]
Sur les architectures K8 et K10, la station de réservation unique de 15 micro-opérations est remplacée par trois stations de réservations, de 8 micro-opérations chacune pour le K8, de 10 pour le K10. Chaque station de réservation entière alimente une unité de calcul entière et une unité de calcul d'adresse. l'unité de calcul d'adresse est reliée à la LSQ, qui n’exécute toujours qu'un seul accès mémoire par cycle. Le multiplieur est relié à la première station de réservation, sur le même port d'émission que l'ALU.
[[File:AMD Husky microarchitecture.png|centre|vignette|upright=3|AMD Husky micro-architecture]]
La micro-architecture K10 a été déclinée en plusieurs versions, nommées Grayhound, Grayhound+ et Husky, Husky étant une architecture gravée en 32 nm dédiée aux processeurs A-3000. L'architecture Grayhound a plus de cache et un ROB plus grand, la Husky est quand à elle un peu plus différente. Elle n'a pas de cache L3, contrairement aux autres architectures K10, ce qui simplifie fortement son sous-système mémoire. Par contre, les ''schedulers''s/stations de réservation et le ROB sont plus grands, pareil pour les files dans l'unité mémoire. Une ALU pour les divisions entières a aussi été ajoutée.
Pour résumer, les architectures K7, K8 et K10 séparent les pipelines entiers et flottants : trois pipelines entiers avec chacun son unité de calcul, et un pipeline flottant avec plusieurs unités de calcul. Les raisons à cela sont assez diverses. Disons que dupliquer des ALU entières simples prend peu de transistors, là où les gros circuits comme le multiplieur ou la FPU ne sont pas dupliqués.
Et cela a un autre avantage : le renommage, ''dispatch'' et l'émission sont plus simples. Les pipelines entiers ont une exécution dans le désordre peu complexe, grâce au grand nombre d'unités de calcul, ce qui fait que le pipeline entier est de seulement 15 cycles au total (chargement et décodage inclus). A l'opposé, la FPU est alimentée par une exécution dans le désordre très complexe, avec banc de registre physique et beaucoup de ressources, mais au prix d'un pipeline flottant plus long de 3 cycles, soit 18 cycles au total.
===La micro-architecture Bulldozer et ses dérivés===
Viennent ensuite les '''micro-architectures Bulldozer''', avec trois révisions ultérieures nommées Piledriver, Steamroller et Excavator. Un détail très important de Bulldozer est que de nombreux circuits sont partagés entre deux cœurs, à un point qui mérite de nombreuses explications. Les processeurs Bulldozer peuvent exécuter deux programmes "en même temps", sur deux cœurs partiellement fusionnés. Cette technique de partage d'unités de calcul entre coeurs s'appelle le '''cluster multithreading''', ou encore les '''architectures à cœurs conjoints''' (''Conjoined Core Architectures''). Elle a aussi été utilisée sur le processeur Cortex A510, qui partageaient leur FPU entre deux coeurs.
Les deux cœurs sont séparés pour ce qui est du chemin de données, du ''back-end''. Ils disposent de deux bancs de registres généraux et les ALU entières sont dupliquées. Toute la logique d'exécution dans le désordre est aussi dupliquée, avec deux ROBs, des stations de réservations séparées, etc. L'unité mémoire est aussi dupliquée, chaque cœur dispose de ses propres ''load queue'' et ''store queue'', ainsi de que son propre cache L1 de données.
En clair, les chemins de données sont totalement séparés, sauf sur deux points : la FPU et les unités SIMD. La FPU est partagée entre deux coeurs, les unités SIMD sont aussi dans ce cas. Le partage de la FPU permet d'éviter de dupliquer trop de circuits et donc d'économiser des transistors. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entraîner des pertes de performances.
Par contre, le ''front-end'' est presque totalement partagé entre deux cœurs. Il n'y a qu'un seul cache d'instruction pour les deux cœurs, la TLB du cache d'instruction est unique, l'unité de prédiction de branchement est partagée entre deux cœurs, les décodeurs d'instruction aussi sont partagés, même la file de µops ! Les seules unités qui sont dupliquées sont la file d'instruction et l'unité de renommage, présentes en deux exemplaires. Concrètement, les deux programmes ont accès au cache à tour de rôle. Un programme a accès au cache à un instant t, mais le cycle suivant pourra être alloué à l'autre programme. Une unité décide de quel programme peut charger ses données, idem pour le décodage.
[[File:AMD Bulldozer microarchitecture.png|centre|vignette|upright=3|Microarchitecture Bulldozer d'AMD.]]
Au-delà de ce partage entre cœurs, Bulldozer a eu un changement assez important : les stations de réservation sont passées de stations de réservation très décentralisés à une station de réservation unique (pour les µops entières/mémoire), d'environ 40 entrées. L’implémentation de la station de réservation a aussi changée. Car 40 entrées, c'est beaucoup pour l'époque. Elle subit quelques simplifications pour la rendre plus rapide et plus économe en transistors.
Les autres processeurs de l'époque utilisaient une station de réservation qui détectait à chaque instant quelle était l'instruction la plus ancienne, qui soit prête pour l’exécution. Mais Bulldozer n'utilisait pas cette technique. A la place, il mémorisait la place de l'instruction la plus ancienne gra^ce à une ''ancestry table''. Et l'instruction la plus ancienne est privilégiée sur toute les autres. Mais si elle n'est pas prête à s'exécuter, la station de réservation choisit une instruction suivant sa position dans la station de réservation (qui reste une mémoire RAM/associative, ne l'oublions pas).
Bulldozer utilise aussi les optimisations du contournement et du banc de registre, vues dans le chapitre sur les CPU superscalaires. Le banc de registres généraux est dupliqué, afin de garder des bancs de registres avec peu de ports de lecture/écriture. Chaque banc de registre généraux a 4 ports de lecture et deux d'écriture, ce qui permet d'alimenter 2 ALU entières. En tout, cela permet d'alimenter 4 ALU entières, avec 8 ports de lecture et 4 d'écriture. Le tout alimente 2 ALU entières, et 2 AGUs/unités mémoire.
Vu que la FPU est partagée entre deux programmes, sa station de réservation a une taille doublée comparé à la normale. Jugez plutôt : 60 entrées pour la station de réservation, là où les microarchitectures concurrentes d'Intel (''Sandy bridge'') utilisait une station de réservation de 54 entrées pour toutes les instructions entières/flottante/mémoire ! Idem pour le banc de registres flottants, qui n'a pas moins de 160 registres flottants !
Pour ce qui est des instructions SIMD, Bulldozer supporte l'extension AVX, qui fait des calculs du des registres de 256 bits, découpés en entiers/flottants de 64 bits, 32 bits, 16 bits, 8 bits, etc. L'unité SIMD ne gére cependant que des calculs sur 128 bits. Les instructions AVX sont donc découpées en deux µops, chacune traitant la moitié d'un registre SIMD. La performance n'est donc pas améliorée par rapport au SSE. Par contre, les unités SIMD gérent nativement l'opération FMA (une multiplication suivie d'une addition).
{|class="wikitable"
|-
|+ Bulldozer
|-
! Station de réservation 1 !! Station de réservation 2 !! Station de réservation 3 !! Station de réservation 4
|-
| FMA - 128 bits || FMA - 128 bits || ALU - 128 bits || ALU - 128 bits
|-
| FMA opérandes entiers - 128 bits || Suffle et permutations || || FSTORE (x87)
|}
La révision Steamroller sépara le ''front-end'' en deux voies distinctes, une par ''thread''. Concrètement, le processeur se contente maintenant de partager la FPU et les unités SIMD entre deux coeurs, pas plus. Pour cela, elle ajouta un second décodeur d'instruction, une seconde file de micro-opération et une seconde unité de renommage de registres, afin d'améliorer les performances.
Niveaux optimisations mineures, les stations de réservation ont été augmentées, elles peuvent mémoriser plus de micro-opérations, idem pour les bancs de registre et les files de lecture/écriture. Un cache de micro-opérations a été ajouté, de même que des optimisations quant au renommage de registre. Des ALU ont aussi été ajoutées, des FPU retirées.
[[File:AMD excavator microarchitecture.png|centre|vignette|upright=3|Microarchitecture Excavator d'AMD.]]
===Les micro-architectures ZEN d'AMD===
Les micro-architectures suivantes sont les '''architectures ZEN 1/2/3/4'''. Elles se ressemblent beaucoup, le cœur de l'architecture reste plus ou moins le même. En passant à la suivante, le nombre de registre virtuel augmente, le ''branch target buffer'' augmente en taille, le ROB et les files d'attente grossissent, les caches de micro-opération aussi, les caches grossissent, etc. Une optimisation intéressante est l'ajout d'un cache de micro-opération, qui améliore grandement les performances du ''front-end'', notamment pour les boucles.
La micro-architecture Zen 1 est illustrée ci-dessous. Comme on le voit, les registres flottants ont une unité de renommage séparée de celle pour les entiers, mais les deux utilisent du renommage à banc de registre physique. Il y a par contre une différence au niveau des ''schedulers'', notées ''scheduler'' dans le schéma. Les µops flottantes utilisent une station de réservation unique, alors qu'il y a une station de réservation par ALU entière. La raison de ce choix est que les opérations flottantes ont un nombre de cycle plus élevé, sans compter que les codes flottants mélangent bien additions et multiplication.
[[File:Zen microarchitecture.svg|centre|vignette|upright=3|Micro-architecture Zen 1 d'AMD.]]
La micro-architecture Zen 5 se distingue fortement des 4 microarchitectures précédentes. Premièrement, elle passe à une station de réservation centralisé pour les µops entières, une seconde pour els µops mémoire, et une troisième pour les µops SIMD/flottantes. Le retour à une station de réservation aussi peu décentralisé n'avait pas été vu depuis la microarchitecture Bulldozer.
Une autre différence est qu'elle utilise un décodeur ''clustered'', tel que vu dans le chapitre sur les processeurs superscalaires, dans la section "Le décodage superscalaire large".
==La microarchitecture Netburst du Pentium 4==
Dans cette section, nous allons voir l'architecture du processeur Pentium 4, qu'on a volontairement laissée de côté précédemment. Pourquoi un tel saut dans le temps ? Parce que le Pentium est complément à part des autres architectures Intel. Le Pentium 4 a représenté une rupture en termes de microarchitecture, qui a été un échec tellement retentissant que les processeurs suivants sont repartis sur la base du Pentium 3.
Il introduisait de nombreuses nouveautés architecturales qui étaient très innovantes. Par exemple, il introduisait le renommage avec un banc de registre physique, qui a été utilisé sur tous les processeurs Intel suivants. Mais la plupart de ces innovations étaient en réalité de fausses bonnes idées, ou du moins des idées difficiles à exploiter. Par exemple, le système de pipeline à ''replay'' n'a été utilisé que sur le Pentium 4 et aucun autre processeur ne l'a implémenté.
===Un focus sur la fréquence d'horloge===
La microarchitecture du Pentium 4 a été déclinée en plusieurs versions, dont les finesses de gravure n'étaient pas les mêmes. La microarchitecture Netburst, utilisée sur le Pentium 4, utilisait un pipeline à 20 étage, augmenté à 32 sur une révision ultérieure. Il a existé quatre révisions de l'architecture : Willamette (180 nm), Northwood (130 nm), Prescott (90 nm) et Cedar Mill (65 nm).
Un point important est que le Pentium 4 était prévu pour fonctionner à haute fréquence. Ses 1,5 GHz étaient impressionnants pour l'époque, les autres processeurs tournant à une fréquence proche du GigaHertzs. Pour cela, la solution retenue par Intel a été un pipeline très long, avec beaucoup d'étages. Le Pentium 4 a été décliné en plusieurs versions assez proches, chacune avec sa propre finesse de gravure qui n'ont pas toute le même pipeline. Les micro-architectures ''Willamette'' et ''Northwood'' avaient un pipeline de 20 étages, alors que les autres processeurs de l'époque avaient entre 10 et 15 étages maximum. Les micro-architectures ''Prescott'' et ''Cedar Mill'' étaient une refonte qui a fait grimper le nombre d'étages à 31 ! Du jamais vu, il s'agit d'un record pour un processeur commercial.
Un pipeline aussi long permet d'exécuter beaucoup d’instructions en même temps, chacune dans un étage, mais aussi d'atteindre de hautes fréquences facilement. Le problème est qu'un pipeline avec autant d'étages a beaucoup de problèmes. Un point important est que la prédiction de branchement est cruciale. Pour rappel, la pénalité en cas de mauvaise prédiction dépend du nombre d'étages avant que le branchement soit résolu. Et les branchements sont résolus soit en fin de décodage, soit dans l'unité de calcul. C'est à dire au milieu du pipeline, soit en fin de pipeline. La pénalité en cas de mauvaise prédiction de branchement était énorme sur le Pentium 4, elle atteignait facilement 30 cycles
Pour compenser, le Pentium 4 avait une prédiction de branchement très performante, pour l'époque. J'insiste sur le pour l'époque. Il utilisait un prédicteur qu'on a déjà abordé dans le chapitre sur la prédiction de branchement, précisément un prédicteur adaptatif à deux niveaux avec un historique global de 16 bits. Il avait aussi un ''Branch Target Buffer'' de 4096 entrées. Mais surtout, il intégrait une sorte de précurseur du cache de micro-opération, appelé le cache de traces, qui est détaillé dans la section suivante.
===Le cache de trace du Pentium 4===
Les décodeurs du Pentium 4 ne font pas décoder les instructions, ils mémorisent le résultat dans un cache de micro-opération un peu particulier, appelé le '''cache de trace'''. Une ligne de cache peut mémoriser 6 micro-opérations, ce qui peu sembler peu mais a été repris sur les micro-architectures suivantes. Mais le cache de trace a une grande différence avec un cache de micro-opération normal. Un cache de micro-opération normal mémorise une instruction par ligne de cache. Une instruction est décodée en plusieurs micro-instructions, qui sont enregistrées dans une ligne de cache. Si l'instruction n'utilise par les 6 micro-opérations disponibles, le reste de la ligne de cache n'est pas utilisé. Mais le Pentium 4 optimise le tout de manière ce à ce que ne soit pas le cas.
Sur le Pentium 4, la contrainte du "une instruction par ligne de cache" est abandonnée. Une ligne de cache mémorise 6 micro-opérations consécutives, qui peuvent appartenir à plusieurs instructions. Par exemple, si le décodeur décode 4 instructions consécutives en 6 micro-opérations au total, alors le tout prendra une seule ligne de cache sur le Pentium 4. Et les 4 instructions consécutives n'ont même pas à être consécutives en mémoire : il peut y avoir des branchements pris entre ces instructions !
{|class="wikitable"
|+ Cache de trace
|-
! Ligne de cache
| ADD || SUB || ADD || MOV || MUL || ''shift''
|-
! Ligne de cache
| colspan="3" | ADD ''load-up'' || MUL || colspan="2" | Branch if Equal
|-
! Ligne de cache
| XOR || colspan="4" | POP || SUB
|-
! ...
| colspan="6" | ...
|}
Pour expliquer cela plus concrètement, nous allons devoir introduire les concepts de trace et de bloc de base. Un '''bloc de base''' (''basic block'') est une suite d'instructions sans branchement, qui est séparé par deux branchements. Le début d'un bloc de base est la destination d'un branchement, un bloc de base se termine avec un branchement. Une '''trace''' est formée en concaténant plusieurs blocs de base. Pour donner un exemple, regardez le code illustré ci-contre. Il est composé d'un bloc de base A, suivi par un bloc de base B, qui peut faire appel soit au bloc C, soit un bloc D. Un tel code peut donner deux traces : ABC ou ABD. La trace exécutée dépend du résultat du branchement qui choisit entre C et D.
Le cache de trace mémorise des traces de 6 micro-opérations consécutives. Les traces sont formées en sortie des décodeurs d'instruction, par de subtiles opérations mélangeant mémorisation, décalage et concaténation. Les circuits qui construisent les traces ne sont pas connus, mais ils doivent certainement être très compliqués. toujours est-il qu'un cache de trace peut mémoriser des traces différentes, même si leur début est le même. Par exemple, prenons deux traces, composées des blocs de base A, B, C et D. La première trace est la trace ABC, la seconde est la trace ABD. Les deux traces auront chacune une ligne de cache dédiée.
Une trace est réutilisable quand le premier bloc de base est identique et que les prédictions de branchement restent identiques. Pour vérifier cela, le tag du cache de traces contient l'adresse du premier bloc de base, la position des branchements dans la trace et le résultat des prédictions utilisées pour construire la trace. Le résultat des prédictions de branchement de la trace est stocké sous la forme d'une suite de bits : si la trace contient n branchements, le n-ième bit vaut 1 si ce branchement a été pris, et 0 sinon. Même chose pour la position des branchements dans la trace : le bit numéro n indique si la n-ième instruction de la trace est un branchement : si c'est le cas, il vaut 1, et 0 sinon.
Si la trace est réutilisée par la suite, elle est lue depuis le cache de traces. Pour savoir si une trace est réutilisable, l'unité de chargement envoie le ''program counter'' au cache de traces, l'unité de prédiction de branchement fournit le reste des informations. Si on a un succès de cache de traces, et la trace est envoyée directement au décodeur. Sinon, la trace est chargée depuis le cache d'instructions et assemblée. Il faut signaler que le cache de trace avait sa propre unité de prédiction de branchement séparée de l'unité de prédiction de branchement normale.
[[File:TraceCache.png|centre|vignette|upright=2|Cache de traces.]]
Le cache de traces réduisait la longueur du pipeline en cas de succès de cache de trace. Quand les instructions étaient lues depuis le cache de trace, les étages avant le cache de trace ne sont pas utilisés, tout se passe comme s'ils étaient retirés du pipeline. C'est la même chose avec le cache de micro-opération des processeurs modernes, mais l'idée n'existait pas encore à l'époque. Le cache de trace mémorise des traces décodées, ce qui fait qu'un succès de cache de trace contournait non seulement le cache d'instruction, mais aussi les décodeurs. Le temps d'accès au cache de trace pouvait être assez élevé, même s'il était comparable au temps d'accès du cache d'instruction.
Le cache de traces a depuis été remplacé par une alternative bien plus intéressante, le cache de micro-opérations, plus flexible et plus performant. Comparé à un cache de trace, la contrainte "une instruction par ligne de cache" simplifie grandement l'implémentation d'un cache de micro-opération. Ne parlons pas de la détection des succès de cache, qui demande d'utiliser les prédictions de branchement. Mais le vrai problème avec le cache de trace est tout autre. Il arrive souvent qu'une micro-opération soit présente dans plusieurs lignes de cache en raison du processus de construction des traces, chose impossible avec un cache de micro-opération. Et c'est un problème, qui réduit la capacité effective du cache de trace. Alors certes, une ligne de cache est plus remplie que sur un cache de micro-opération, on est certain que les 6 micro-opération par ligne de cache sont remplies. Mais la redondance réduit grandement cet avantage.
===Les unités de calcul entières du Pentium 4===
Sur le Pentium 4, les ALU entières étaient cadencées à une fréquence double de celle du processeur. Les ALU entières pouvaient exécuter deux micro-opérations par cycle, ce qui fait que les ports d'émissions reliés aux ALU devaient eux aussi fonctionner à double fréquence. 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.
Précisons que seules les ALU entières étaient à double fréquence, pas le multiplieur, pas le ''barrel shifter''. Pour simplifier, nous allons parler d'additionneur plutôt que de l'ALU entière, ce qui sera plus proche de la réalité. Et l'implémentation de l'additionneur du Pentium 4 était très innovante. L'additionneur pouvait exécuter deux additions par cycle, même si les deux additions ont une dépendance. Mais n'allez pas croire que l'implémentation était intuitive, avec un additionneur 32 bit basique très rapide. Non seulement l'additionneur fonctionnait à double fréquence, mais il était aussi pipeliné, avec un système de contournement interne !
Les additionneurs étaient pipelinées, d'une manière très simple. Une addition 32 bits était découpée en trois étapes : deux additions de 16 bits, une dernière étape pour mettre à jour le registre d'état. Pour cela, chaque additionneur était composé de deux additionneur 16 bits chacune, placées l'une après l'autre, avec un registre de pipeline entre les deux. L'additionneur prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible, le second calculait les 16 bits de poids fort lors du second cycle. Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Une addition se fait donc en deux étapes, sauf que c'est compensé par le fait que l'additionneur fonctionnait à une fréquence double de celle du processeur ! 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. Deux additions consécutives s'exécutaient donc en 1 cycle et demi, alors qu'on aurait cru au premier abord que cela prendrait seulement un cycle. Si on fait les calculs, on s’aperçoit que le rythme de croisière est cependant proche de 2 additions par cycle, bien qu'inférieur. 3 additions consécutives se font en deux cycles, 5 additions en 3 cycles, 7 en 4 cycles, etc.
Et le Pentium 4 ajoutait un système de contournement interne à l'ALU. En clair, si une addition utilise le résultat de l'addition précédente, les deux peuvent s'exécuter en un cycle d'horloge et demi. Les 16 bits de poids faible de la première addition sont disponibles après un cycle ALU, ce qui permet de démarrer le calcul de la seconde addition au cycle suivant.
===L'exécution dans le désordre superscalaire du P4===
Le renommage de registres se fait avec un banc de registres physiques avec une table d'alias. Précisément, il avait 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 cas de mauvaise prédiction de branchement, le pipeline est remis à zéro et l'unité re nommage doit être réinitialisée de manière à reprendre à la bonne instruction. Pour cela, la table d'alias spéculative était réinitiailisée avec le contenu de la seconde.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium III and Netburst - 4332527833.jpg|centre|vignette|upright=2.5|Tables d'alias du Pentiçum 4.]]
Le Pentium 4 avait une exécution dans le désordre très limitée, basée sur la présence de deux files de micro-opération : une pour les accès mémoire, une autre pour les autres instructions. La seconde file regroupait opérations entières et flottantes, elles n'étaient pas séparées. Avec ces deux files, les instructions mémoire étaient exécutées dans l'ordre du programme, les instructions arithmétiques s'exécutaient aussi dans l'ordre du programme, mais une instruction arithmétique pouvait passer avant une instruction mémoire et inversement. L'avantage est que cela permettait de faire des lectures en avance, c'était une forme limitée de lecture non-bloquantes.
Il s'agit bel et bien de deux files d'instructions, pas de ''schedulers'' ni de stations de réservation. Le Pentium 4 est le seul processeur commercial qui a utilisé des files de micro-opération séparées, tous les autres utilisent des ''schedulers'' : centralisés pour Intel, décentralisés pour AMD (en général). Le Pentium 2 et 3, bien qu'antérieurs, utilisait une station de réservation unique. Cela peut sembler être un retour en arrière, mais les files d'instructions sont bien plus larges : de 42 micro-opérations pour le Pentium 3, on passe à 120 micro-opérations pour le Pentium 4. Et vu la longueur du pipeline, qui fait qu'il y a plus d'instructions en vol, c'était une nécessité. Mais cela n'aurait pas été possible en utilisant des stations de réservation, pour des raisons de consommation électrique et/ou de budget en transistors, ce qui fait que passer à une file de micro-opération été la solution retenue.
Pour les accès mémoire, le Pentium 4 utilisait donc une file de µops mémoire unique, couplée à une file d'écriture (non-représentée sur les schémas qui suivent). La file d'écriture du Pentium 4 était de 24 écritures maximum et gérait le ''Store-to-load forwarding''. Le processeur pouvait émettre une lecture et une écriture à chaque cycle. Il y avait un port d'émission pour les lectures et un autre pour les écritures, tous deux ayant chacun leur propre unité de calcul d'adresse.
Le processeur contenait 3 ALU entières, 2 unités de calcul d'adresse et une FPU. La FPU était complétée par une unité pour faire des copies entre registres flottants, des opérations MOV. Pour les AGU, il y en avait une dédiée aux lectures, une autre pour les écritures. Le tout était relié aux ports d'émissions comme suit :
[[File:Ports d'émission du Pentium 4.png|centre|vignette|upright=2.5|Ports d'émission du Pentium 4]]
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-4 Execution Ports - 4332668231.jpg|centre|vignette|upright=2.5|Ports d'émission du Pentium 4]]
Le processeur utilisait deux réseaux de contournement séparés : un pour les opérations flottantes, un pour les opérations entières. Le réseau de contournement pour les opérations entières est aussi relié aux unités de calcul d'adresse. Jusque là, rien de surprenant, le chemin de données du processeur est assez classique.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-4 Microarchitecture - 4472656667.jpg|centre|vignette|upright=3|Microarchitecture du Pentium 4.]]
===Le ''replay pipeline''===
Le processeur est un processeur triple émission : il peut charger et décoder 3 µops par cycle, le ROB peut terminer 3 µops par cycle, etc. Pourtant, les ports d'émission peuvent émettre 6 instructions par cycle : 4 ports, dont deux à double fréquence. Une telle différence s'explique par l'usage d'un ''replay pipeline'', dont nous avons déjà parlé dans ce cours.
Pour rappel, le Pentium 4 suppose que les lectures font tous un succès de cache L1. Si une opération arithmétique utilise la donnée lue comme opérande, le processeur l'émet immédiatement, l'opérande sera disponible une fois l'opération en entrée de l'ALU entière. Mais s'il s'est trompé, le processeur ré-exécute l'instruction après un temps d'attente de quelques cycles, pour se caler sur la latence du cache L2. Et si il y a un défaut de cache L2, l’instruction attend encore. Cela demande de ré-exécuter des instructions émises à tord, ce qui fait que le processeur doit avoir la capacité d'exécution pour. Ce pourquoi le processeur peut émettre 6 µops dans les unités de calcul : 3 µops normales et 3 µops ré-exécutées.
==Les microarchitectures x86 de Cyrix et Via==
Les processeurs x86 grand public sont actuellement conçus et commercialisé par deux acteurs : Intel et AMD. Il faut dire que le jeu d'instruction x86 demande une licence pour être utilisée, qu'Intel et AMD refusent de dsitribuer à leurs concurrents, sauf en de très rares occasions. Cependant, il y a eu dans le passé un troisième acteur, qui a reussit à acquérir les droits de la licence x86 : Cyrix, qui a ensuite été racheté par National Semiconductor, lui-même acquis par Via.
===Les architectures compatibles 386 et 486 de Cyrix===
Cyrix a produits plusieurs CPUs et même des co-processeurs x87, avant d'être racheté. Sa carrière commence avec le Cyrix FasMath, un co-processeur arithmétique pour calculs flottants, compatible avec le x87 d'Intel. Il a ensuite produits plusieurs clones du processeur 386 d'Intel, nommés 486SLC et 486DLC. Une dénomination assez trompeuse, vous en conviendrez, mais qui n'est heureusement pas passée inaperçue et leur a valu de nombreuses critiques. Néanmoins, leurs processeurs étaient plus performants que le 386 de base, ce qui fait qu'ils étaient des upgrades corrects pour les PC avec un 386 (les cartes mères compatibles avec le i386 n’étaient pas compatibles avec le i486). Le 486SRX2 et le 486DRX2, sortis plus tard, étaient des versions avec une fréquence encore plus élevée, ce qui augmentait leurs performances.
Cyrix récidiva avec le 5x86, un processeur offrant des performances s'approchant du Pentium, mais pour la plateforme 486. Le processeur supportait seulement les instructions du 486, pas les instructions ajoutées sur le Pentium. Son bus était similaire à celui du 486. Mais sa microarchitecture se rapprochait du Pentium, bien que n'étant pas aussi complexe. Il y avait une file d'instruction capable de mettre en attente 3 instructions, un cache unifié, une unité de prédiction de branchement. Malheureusement, la prédiction de branchements avait été désactivée en raisons de bugs matériels qui faisaient crasher le processeur. Niveau ALU, mais il y avait une FPU, une ALU entière et une unité mémoire. Mais il n'y avait pas de double émission.
[[File:Cy5x86 arch (cropped).png|centre|vignette|upright=2.5|Cy5x86 arch (cropped).]]
===Le Cyrix 6x86 : une architecture superscalaire avancée===
Le '''Cyrix 6x86''' est là où la superscalarité apparait dans la gamme Cyrix. Le processeur était non pas une upgrade pour des cartes mères existantes, mais un concurrent direct du Pentium. C'était un processeur à double émission, avec exécution dans le désordre et renommage de registre.
L'organisation de ses caches est assez originale. Il avait un cache principal de 16 kibioctets, qui mémorisait à la fois données et instructions, couplé à un cache d'instruction de 256 octets. En termes modernes, on dirait qu'il disposait d'un cache L2, couplé à un cache L1 d'instructions, mais sans cache L1 pour les données. Le cache unifié/L2 était un cache double port. Le cache d'instruction était totalement associatif.
[[File:Cy6x86 arch.png|centre|vignette|upright=2.5|Cy6x86 microarchitecture.]]
Le processeur supportait une double émission bien plus complète que celle du Pentium. Il disposait de deux pipelines entiers, nommés X et Y. Le pipeline X était le seul à pouvoir exécuter branchements et opérations flottantes, c'était le seul connecté à la FPU. Par contre, il était possible d'émettre un branchement ou une opération flottante dans le pipeline X, tout en émettant une seconde instruction dans le pipeline Y. Ce que le Pentium ne pouvait pas faire. Certaines instructions ne pouvaient cependant pas être appariées avec une autre : les multiplications/divisions, les ''string instruction'', les ''far jumps'', les accès aux entrées-sorties, et des instructions systèmes.
La FPU pouvait exécuter des instructions en parallèle de l'ALU entière. Elle avait une file de µops de 4 µops flottantes. Elle avait aussi sa propre ''Store queue'', spécifiquement dédiée aux écritures flottantes, séparée de la ''Store Queue'' pour les écritures "normales", celles des registres généraux. Cette séparation est possible du fit que les registres flottants et généraux sont séparés sur le jeu d'instruction x86.
[[File:Cyrix 6x86 arch.svg|centre|vignette|upright=2.5|Cy6x86 microarchitecture.]]
Le Cyrix 6x86 avait de nombreux avantages sur le Pentium. Il intégrait notamment du renommage de registres pour les registres entiers, ainsi que le contournement, ce que ne faisait pas le Pentium. Et il pouvait faire de la double émission entière-flottante, alors que le Pentium ne pouvait pas émettre de seconde µop en parallèle avec une opération flottante. Tout cela le rendait bien plus performant que le Pentium, sur le papier. Sauf que sa FPU était peu performante, proche de celle du 5x86. Les concepteurs du processeur avaient mis le paquet sur les opérations entières, en délaissant les opérations flottantes. Les applications développées pour le Pentium, qui répartissaient au mieux leurs calculs entre FPU et ALU entière, marchaient donc moins bien sur le 6x86.
Le successeur du 6x86, le '''Cyrix Jalapeno''', était un concurrent du Pentium 2/3. Son pipeline passait de 7 à 11 étages, ce qui permettait des augmentation de fréquence importante. Son cache était quadruplé en taille et passait à 8 voies. Et pour ce qui de la superscalarité, il était maintenant capable d'émettre deux µops flottantes par cycle : une partait dans l'additionneur flottant, l'autre dans un multiplieur flottant.
===Les processeurs de marque Via===
Après deux rachats, la licence x86 de Cyrix est passée dans les main de Via. Via avait racheté Cyrix, mais aussi Centaur, un autre concepteur de processeur. Via produisit plusieurs processeurs x86, basés sur des design provenant non pas de Cyrix, mais de Centaur, pour leur microarchitecture. Le premier processeur de cette lignée, le mal-nommé Cyrix III, a d'ailleurs été renommé en C3, parce qu'il n'avait aucun lien avec les microarchitectures de Cyrix et n'utilisait pas leurs technologies.
Les '''processeurs Via C3''' et '''Via C7''' étaient des processeurs 32 bits. Ils étaient prévus pour des ordinateurs portables et ciblainet une faible consommation d'énergie, plutôt que des hautes performances. Ils n'avaient pas d'exécution dans le désordre, mais utilisaient une superscalarité limitée à la double émission.
Le C3 regroupe en réalité deux microarchitecture semblables. Les modèles Samuel 2 et Ezra utilisaient la première version de la microarchitecture, alors que les processeurs Nehemiah avaient une version améliorée de celle-ci. Le pipeline passait de 12 à 16 étages, sa FPU est améliorée, les extensions SIMD sont revues (passage au SSE, abandon du 3DNow!). Les processeurs C7 utilisent cette même microarchitecture, bien qu'améliorée sur quelques détails.
[[File:VIA Nano Architecture Blockdiagram.jpg|vignette|upright=1.5|VIA Nano Architecture.]]
Par la suite, Via sortit le '''Via Nano''' en 2008 . C'était le premier CPU 64 bits de la marque. Il avait tout d'un design moderne, avec exécution dans le désordre, prédiction de branchement complexe, hiérarchie de cache classique, préchargement, etc. Il implémentait la superscalarité en triple émission. Pour cela, son ''front-end'' chargeait 16 octets par cycle, qui étaient accumulés dans une file d'instruction. Trois décodeurs lisaient les instructions dans cette file, puis envoyaient trois µops dans une file de µops de taille inconnue. Le renommage de registre pouvait lui aussi renommer trois µops par cycle.
Niveaux unités, il avait deux ALU entières, une unité LOAD, une unité STORE, deux unités SIMD (une pour les additions SIMD, une pour les multiplications SIMD). Chaque unité avait sa propre file de µops, qui faisait entre 8 et 12 entrées. Les unités LOAD et STORE avaient chacune leur propre file de µops, il n'y avait pas de file de µops partagée entre les deux. L'unité LOAD alimentait une ''load queue'' de 16 lectures, alors que l'unité STORE alimentait une ''store queue'' de 16 écritures. Les unités de calcul sont décrites dans le tableau ci-dessous :
{|class="wikitable"
|-
! !! ALU 1 !! ALU 2 !! FPU/SIMD 1 !! FPU/SIMD 2 !! LOAD !! STORE
|-
! File de µops
| 12 entrées || 11 entrées || 8 entrées || 24 entrées || 8 entrées || 8 à 13 entrées, selon les sources
|-
! rowspan="5" | Opérations
| Opérations entières de base || Opérations entières de base || || || LOAD || STORE
|-
| Décalages et rotations || Multiplication entière 32 bits || Multiplication entière 64 bits || || ||
|-
| CMOV || CMOV || Division entière 64 bits || Division entière 64 bits || ||
|-
| ''Add With Carry'' || Branchements || Division flottante || || ||
|-
| || || SIMD opérations entières + FMUL 128 bits || SIMD opérations entières + FADD 128 bits || ||
|}
Les processeurs qui ont suivis sont des processeurs commercialisés uniquement en Chine, à destination du marché chinois et subventionnés (si ce n'est plus), par l'administration chinoise.Le peu d'informations qui sont disponibles dessus sont disponibles sur le substack ''Chips and cheese'', qui a notamment quelques articles sur les processeurs Longsoon. Pour une histoire complète et une review en profondeur des architectures de Via, je conseille les articles du blog ''Chips and cheese'' :
* [https://chipsandcheese.com/p/the-weird-and-wacky-world-of-via-the-3rd-player-in-the-modern-x86-market The Weird and Wacky World of VIA, the 3rd player in the “Modern” x86 market]
* [https://chipsandcheese.com/p/the-weird-and-wacky-world-of-via-part-2-zhaoxins-not-quite-electric-boogaloo The Weird and Wacky World of VIA Part 2: Zhaoxin’s not quite Electric Boogaloo]
* [https://chipsandcheese.com/p/zhaoxin-part-3-a-sort-of-anti-climax Zhaoxin Part 3: A Sort of Anti-Climax]
* [https://chipsandcheese.com/p/via-part-4-a-deep-dive-into-centaurs-last-cpu-core-cns VIA Part 4 – A Deep Dive into Centaur’s Last CPU Core: CNS]
* [https://chipsandcheese.com/p/examining-centaur-chas-die-and-implementation-goals Examining Centaur CHA’s Die and Implementation Goals]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les premiers processeurs Intel
| prevText=Les premiers processeurs Intel
| next=Les microarchitectures pour le x86 : basse consommation
| nextText=Les microarchitectures pour le x86 : basse consommation
}}
</noinclude>
7lhrboo7wwaxj600x8z0w08wb2a6xuw
Fonctionnement d'un ordinateur/Le contournement (data forwarding)
0
82832
774589
774564
2026-10-10T12:08:18Z
Mewtow
31375
/* Le contournement sur les pipelines multi-cycle */
774589
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
==Le contournement des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre, mais 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]]
Mais 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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
h7mfhwkdei9a1v0mrauljz8vhp4pwqu
774590
774589
2026-10-10T12:08:25Z
Mewtow
31375
/* Le contournement de l'unité de calcul */
774590
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
===Le contournement avec plusieurs unités de calcul===
La présence de plusieurs unités de calcul complexifie les interconnexions. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque ALU sur l'entrée de tous les autres. Le nombre d'interconnexion est alors assez important. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2 * N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable sur les processeurs modernes, qui ont facilement une dizaine d'unité de calcul. A la place, les interconnexions sont simplifiées, au prix d'une petite perte de performance.
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.
Il est fréquent que les unités de calcul d'adresse utilisent des opérandes calculées par les ALU entières. En conséquence, il faut relier les sorties des ALU entières aux unités de calcul d'adresse. Cela fait des interconnexions nécessaires, qui ont un impact important pour les performances. Mais les connexions inverses, de l'ALU de calcul d'adresse vers l'ALU entière, ne servent pas à grand chose. La connexion ALU-AGU se fait dans un sens seulement.
: Dans le même genre, la sortie de l'unité mémoire est reliée aux entrées de toutes les autres unités de calcul, pas le choix. Mais cela dépend un peu du processeur.
Le contournement doit prendre en compte la présence d'une FPU si elle existe. 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 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 avec la ou les FPUs. Il arrive que le processeur ait plusieurs FPUS et c'est la même chose, le réseau de contournement entre FPUs est juste plus complexe.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Les pipelines multicycles peuvent, en théorie, éviter de bloquer le pipeline grâce au contournement. Ce n'est plus le cas en présence d'instructions multicycles. Un exemple parlant est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Les deux instructions ne peuvent pas être émises l'une après l'autre sans bloquer le pipeline. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émises, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu.
L'unité d'émission doit retarder l'émission d'une instruction tant que le résultat n'est pas disponible, 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 des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre, mais 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]]
Mais 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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
912xco5kskef46w23ldos04xb6n5onj
774591
774590
2026-10-10T12:14:43Z
Mewtow
31375
/* Le contournement avec plusieurs unités de calcul */
774591
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
===Le contournement avec plusieurs unités de calcul===
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement avec des isntructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Les pipelines multicycles peuvent, en théorie, éviter de bloquer le pipeline grâce au contournement. Ce n'est plus le cas en présence d'instructions multicycles. Un exemple parlant est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Les deux instructions ne peuvent pas être émises l'une après l'autre sans bloquer le pipeline. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émises, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu.
L'unité d'émission doit retarder l'émission d'une instruction tant que le résultat n'est pas disponible, 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 des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre, mais 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]]
Mais 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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
tayvfxghe4xjm1d6fcvce2eov8sp2k9
774592
774591
2026-10-10T12:17:02Z
Mewtow
31375
/* Le contournement avec des isntructions multicycles */
774592
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
===Le contournement avec plusieurs unités de calcul===
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement avec des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Le contournement des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre, mais 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]]
Mais 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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
mxzr3uleoataw23y201w7e757z8bdmy
774593
774592
2026-10-10T12:17:49Z
Mewtow
31375
/* Le contournement des lectures mémoire */
774593
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
===Le contournement avec plusieurs unités de calcul===
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement avec des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Le contournement des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
===Le contournement des lectures mémoire : théorie===
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, mais 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]]
===Le contournement des lectures mémoire sur le pipeline RISC classique===
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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
gl1rdtynkw1vy6ukdytqc2p0amnezi5
774594
774593
2026-10-10T12:18:07Z
Mewtow
31375
/* Le contournement des lectures mémoire : théorie */
774594
wikitext
text/x-wiki
Les dépendances RAW correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Vous vous dites que le résultat d'une opération doit être enregistré dans les registres avant de pouvoir ê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é. Pour gérer les dépendances RAW, on a deux grandes solutions. La première solution est de retarder l'exécution de la seconde instruction tant que la première n'a pas enregistré son résultat dans les registres, en bloquant le pipeline. La technique du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
==Le contournement de l'unité de calcul==
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
===Le contournement avec plusieurs unités de calcul===
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement avec des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Le contournement des lectures mémoire==
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
===Le contournement des lectures mémoire : théorie===
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]]
===Le contournement des lectures mémoire sur le pipeline RISC classique===
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 écritures mémoire==
Après avoir vu le contournement pour les lectures, voyons maintenant le contournement des écritures. Au premier abord, cela peut paraitre paradoxal. Une écriture ne fournit pas de résultat, il n'y a rien à contourner. Cependant, il faut prendre en compte le fait que le processeur gère des exceptions précises. Pour rappel, les exceptions précises imposent que les écritures se fassent à la toute fin du pipeline, dans l'étage de ''Writeback''. Pour cela, les écritures sont sont mises en attente jusqu'à l'étage de ''Writeback''.
Pour rappel, la mise en attente se fait grâce à une mémoire spécialisée, appelée la file d'écriture. C'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre normal des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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]]
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
==Le contournement avec remise en ordre des écritures==
Nous avons vu il y a quelques chapitres que les pipelines dynamiques on un problème d'inversion des écritures. Deux instructions peuvent s'exécuter l'une après l'autre et enregistrer leurs résultats dans l'ordre inverse. Mais les processeurs incorporent souvent des techniques pour remettre les écritures dans l'ordre, qu'il s'agisse de registres d'échelonnement, d'un tampon de ré-ordonnancement, etc. L'idée est que les résultats à écrire ne sont pas enregistrés dans les registres directement, mais sont mis en attente dans une mémoire temporaire, pour être enregistrés dans les registres seulement quand les conditions sont réunies. Nous ferons quelques rappels sur cette mémoire dans ce qui suit.
La présence de cette mémoire temporaire perturbe un peu le contournement. Elle n'impacte pas les interconnexions entre unités de calcul. Mais le contournement devrait en théorie lire les opérandes depuis cette mémoire temporaire. 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 contournement avec les registres d'échelonnage===
La première technique que nous allons voir est celle des registres d'échelonnage. Pour rappel, l'idée est que toutes les instructions prennent le même nombre de cycles d'horloge. Par exemple, toutes les opérations doivent faire 5 cycles. Si l'addition fournit son résultat en un cycle, il sera enregistré dans les registres avec un retard de quatre cycles. Le retard dépend de l’opération à effectuer, en fonction de combien de cycles elle prend dans l'ALU. En clair, toutes les instructions ont le même nombre d'étages, mais certains étages sont inutiles pour certaines instructions. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
Reste à ajouter des cycles de retard, qui servent juste à retarder l'enregistrement dans les registres. La solution la plus simple insére des ''staggering registers'', des '''registres d'échelonnage''', en sortie des ALU. Les registres d'échelonnage peuvent être regroupés dans un banc de registre unique.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Regsitres d'échelonnage]]
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
Pour les pipelines de longueur fixe, le contournement ne doit pas uniquement relier la sortie de l'ALU, mais doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===Le contournement avec un tampon de ré-ordonnancement===
Les processeurs modernes ajoutent un tampon de ré-ordonnancement, pour remettre les écritures dans les registres dans l'ordre du programme. Les données à écrire sont mise en attente dans le tampon de ré-ordonnancement, qui est une sorte d'hybride entre mémoire cache et mémoire FIFO.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les données stockées dans le ROB sont certes mises en attente, mais qu'il est possible que certains calculs en aient besoin. 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. Mais pour faire ainsi, il faut que le processeur implémente des techniques dites de renommage de registre que nous verrons dans quelques chapitres.
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. 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.
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.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}{{autocat}}
</noinclude>
r6knmkjbk1ut7x6kpk75ys925w2eoc8
Fonctionnement d'un ordinateur/Les premiers processeurs Intel
0
83867
774624
765484
2026-10-10T12:55:05Z
Mewtow
31375
/* Les optimisations du bus mémoire sur le 486 */
774624
wikitext
text/x-wiki
Les chapitres précédents ont été assez chargés, et nous ont appris comment fonctionne un pipeline simple. Découper un processeur en étape, gérer les branchements, découpler les étages avec des FIFOs, gérer les dépendances de données, implémenter le contournement. De nombreux processeurs RISC intègrent un pipeline similaire à celui décrit dans les chapitres précédents, souvent implémenté afin d'avoir de bonnes performances. L'implémentation est claire, classique, assez propre. En comparaison, les premiers processeurs Intel avaient des pipeline bien différents, suffisamment pour les aborder dans un chapitre dédié.
Par premiers CPU Intel, nous voulons parler des processeurs avant le Pentium. Il s'agit du 8086, du 286, du 386 et du 486. Le Pentium est un peu particulier, car c'est un processeur superscalaire, ce qui fait qu'on n'en parlera pas ici. Les premiers CPU Intel avaient un pipeline très simple, assez semblable à un pipeline ''Fetch-Decode-Exec''. Cependant, l'implémentation de ce pipeline a été compliquée par plusieurs point de blocage. Le premier est le fait que ce soit des processeurs CISC, avec beaucoup d'instructions microcodées. Le second est l'absence de mémoire cache intégrée (sauf pour le 486) et le fait que ce ne soient pas des architectures Harvard. Il est intéressant de voir les premiers CPU Intel pour voir comment les processeurs CISC se sont débrouillés pour surmonter ces problèmes.
==Les pipelines faiblement et fortement couplés==
L'évolution des processeurs Intel a été une lente transition d'un type de pipeline vers un autre. Le 8086, le 286 et le 386 utilisaient des '''pipelines faiblement couplés''', alors que le 486 utilisait un '''pipeline fortement couplé'''. Les pipelines fortement couplés équilibrent leurs étages au mieux, afin que chaque étage prennent approximativement le même temps. Le résultat est que chaque étage fait son travail en un cycle d'horloge, pas plus. À l'opposé, les pipelines faiblement couplés ont des étages inégaux, dont certains font plusieurs cycles d'horloge.
Précisons que la définition n'interdit pas d'avoir des instructions multicycles. Les accès mémoire et quelques instructions complexes peuvent passer plusieurs cycles dans le même étage. Mais pour les instructions simples, à savoir les additions/soustractions/décalages/opérations bit à bit ; qui ne font pas d'accès mémoire et agissent uniquement sur des registres. Elles doivent idéalement s'exécuter en un seul cycle sur un pipeline fortement couplé. De même, il est possible d'avoir des instructions simples qui prennent plusieurs cycles, à condition qu'elles soient pipelinées. Par exemple, si une addition prend deux cycles, alors elle s'exécute en deux étapes qui peuvent exécuter deux calculs différents en même temps : un dans le premier étage, un autre dans le second étage.
Pour donner un exemple, sur le 286 et le 386, beaucoup d'opérations basiques prenaient plusieurs cycles. Les additions, soustractions, opérations bit à bit et décalages prenaient toutes 2 cycles d'horloge. Et elles n'étaient pas pipelinées, ce qui fait que l'unité de calcul bloquait le pipeline quand une instruction s'exécutait dedans. Et il en est de même avec l'étage de décodage : il pouvait décoder une instructions simples en un seul cycle d'horloge, mais les instructions complexes prenaient plus de temps à décoder.
Les pipelines fortement couplés peuvent exécuter plusieurs instructions en même temps sans trop de problèmes, mais il est fréquent que certains étages soient inutilisés, même dans des circonstances optimales. Au contraire, un pipeline fortement couplé s'approche plus facilement de son optimum théorique. Un pipeline fortement couplé à N étages s'approchera des N instructions exécutées en même temps, alors qu'un pipeline faiblement couplé aura tendance à avoir des étages inutilisés.
===Les pipelines à registres et à FIFO===
Dans les implémentations les plus simples, les pipelines fortement couplés utilisent des registres entre chaque étage, alors que les faiblement couplés n'ont pas de choix : ils doivent utiliser des FIFOs entre chaque étage. L'implémentation est alors plus simple sur les pipelines fortement couplés, mais cela a un prix : au moindre problème (dépendance de données, branchements), le pipeline stalle pour éviter tout problème. Un pipeline faiblement couplé n'a pas ce problème, certains étages peuvent prendre de l'avance au lieu de se bloquer.
Et cette flexibilité en cas de stall explique que le 286 et le 386 utilisaient des pipelines faiblement couplés, comme nous allons le voir. Sans compter que l'implémentation était aussi plus simple. Avoir des étages déséquilibrés permettait une implémentation simple. Il suffisait de placer une file d'instructions et une file de micro-opérations pour obtenir un pipeline ''fetch-decode-exec''. Pas besoin de découper le décodeur pour le pipeliner, pas besoin de pipeliner l'ALU, on les laisse intacts.
Mais il s'agit là des implémentations les plus simples. De nos jours, de nombreux pipelines fortement couplés utilisent des FIFOs pour découpler leurs étages, ce qui permet d'avoir le meilleur des deux mondes. L'usage de files d'instruction et de files de micro-opérations est courante sur les processeurs haute performance modernes. Mais ils combinent celles-ci avec des pipelines équilibrés, où chaque étage fait un cycle d'horloge. Les deux détails, équilibrage des étages et découplage avec des FIFOs, entrainent un gain de performance, qui s'additionne quand on les combine.
==La ''prefetch input queue'' : une file d'instruction==
Les premiers processeurs Intel intégraient une file d’instruction quelque peu particulière, appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Elle a été utilisée sur tous les processeurs avant le Pentium, à savoir les processeurs Intel 8 et 16 bits, ainsi que sur le 286 et le 386.
Avec la PIQ, pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge les instructions suivantes, jusqu'à une limite de 4/6/16/32 octets (la limite dépend du processeur). La ''prefetch input queue'' permet donc d'implémenter une forme de pipeline ''Fetch + Decode/Exec''. Mais il est aussi possible de dire qu'il s'agit d'une technique de préchargement d'instructions, vu qu'elle permet de précharger plusieurs instructions si les circonstances le permettent. La différence entre les deux est assez subtile et les frontières sont assez floues.
La PIQ permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. Les CPU Intel avant le 486 n'avaient pas de cache, ni d'architecture Harvard. La conséquence est qu'il n'est pas possible de lire une instruction et faire un accès mémoire en même temps. Si l'instruction en cours d'exécution effectue des accès mémoire, ceux-ci sont prioritaires sur tout le reste, le préchargement de l'instruction suivante est mis en pause. Le préchargement n'est possible que si l'instruction en cours d'exécution ne travaille que sur des registres.
La PIQ faisait 6 octets sur le 8086 et le 286, puis est montée à 16 octets sur le 386, pour atteindre 32 octets sur le 486. Le 8088, une version simplifiée du 8086, avait une PIQ de 4 octets seulement. La taille idéale de la FIFO dépend de nombreux paramètres. On pourrait croire que plus elle peut contenir d'instructions, mieux c'est, mais ce n'est pas le cas, pour des raisons que nous allons expliquer dans quelques paragraphes.
Vous remarquerez que j'ai exprimé la capacité de la PIQ en octets et non en instructions. La raison est que la PIQ s'est adaptée au fait que le jeu d'instruction x86 a des instructions de taille variable. Ce n'est donc pas tellement une file d'instruction au sens strict. Le jeu d'instruction x86 a des instructions dont la taille varie entre un octet et 15 octets. Vous noterez que la PIQ est mieux exploitée quand les instructions sont courtes, ce qui permet de précharger plus d'instructions.
===L'interaction avec le décodage d'instruction===
La PIQ dispose d'un port d'écriture sur lequel les instructions préchargées sont envoyées, et un port de lecture où le décodeur lit la prochaine instruction à décoder.
La PIQ du 8088 était la plus simple, niveau implémentation. Sur le 8088, tout faisait un octet : le bus de données, les ports de la PIQ, et j'en passe. La PIQ du 8088 était composée de 4 registres de 8 bits, avec un port d'écriture de 8 bits pour précharger les instructions octet par octet, et un port de lecture de 8 bits pour alimenter le décodeur. En clair, tout faisait 8 bits.
Le 8086 était quant à lui un processeur 16 bits, ce qui changeait un peu les choses. Sa PIQ était adaptée à un bus de données de 16 bits, à savoir qu'elle mémorisait ses 6 octets avec 3 registres de 16 bits. Le préchargement des instructions se faisait deux octets à la fois, un registre à la fois, grâce à un port d'écriture de 16 bits. Un détail important est que le préchargement était aligné sur 16 bits en mémoire. Si l'instruction à charger n'était pas alignée sur 16 bits, elle doit être chargée en plusieurs fois. Par exemple, une instruction de 2 octets non-alignée sera chargée en deux fois : un premier accès qui charge deux octets, dont un est oublié, et un second accès pour l'octet restant, dont l'autre octet aussi est oublié.
Par contre, le décodage se faisait un octet à la fois. Le décodeur lit un octet, puis éventuellement le suivant, et ainsi de suite jusqu'à atteindre la fin de l'instruction. Le port de lecture faisait ainsi un octet sur le 8086, comme sur le 8088. Le port de lecture incorporait pour cela un multiplexeur, qui sélectionnait l'octet adéquat dans un registre 16 bits. L'avantage est que le préchargement est plus efficace, car il préchargeait plus d'octets par cycle. Vu que la PIQ ne peut pas précharger d'instruction à chaque cycle, du fait des accès mémoire, le fait de précharger plus à chaque accès compense.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Le 386 était un processeur 32 bits, ce qui fait que sa PIQ contenait 4 registres de 32 bits et un port d'écriture de 32 bits. Et le port de lecture pour le décodeur d'instruction faisait lui aussi 32 bits. Il était cependant capable de gérer des instructions de 8 et 16 bits. Il pouvait dépiler entre 1 et 4 octets dans la PIQ, suivant la taille de l'instruction voulue. De plus, les instructions préchargées n'étaient pas parfaitement alignées sur 32 bits : une instruction pouvait être à cheval sur deux registres 32 bits. Le processeur incorporait donc des circuits d'alignement, semblables à ceux utilisés pour gérer des instructions de longueur variable avec un registre d'instruction.
===Les signaux de commande entre décodeur et PIQ===
Les instructions varient entre 1 et 15 octets, mais tous ne sont pas utiles au décodeur d'instruction. Le décodeur d'instruction n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage et les registres. Les octets de préfixe sont aussi utiles au décodeur, mais la plupart des instructions n'en ont pas. Les constantes immédiates, adresses absolues et décalages, ne passent pas par le décodeur d'instruction. Ils sont lus depuis la PIQ, mais sont copiées dans des registres dédiés, sous la commande du décodeur d'instruction. En clair, le décodeur a besoin de lire deux octets maximum depuis la PIQ, avant de passer à l’instruction suivante. Les autres octets étaient envoyés ailleurs, typiquement dans le chemin de données.
Par exemple, prenons le cas d'une addition entre le registre AX et une constante immédiate. L'opcode était envoyé au décodeur, mais pas la constante immédiate. Elle était lue octet par octet et mémorisée dans un registre temporaire placé en entrée de l'ALU. Idem avec les adresses immédiates, qui étaient envoyées dans un registre d'interfaçage mémoire sans passer par le décodeur d'instruction. Pour cela, la PIQ était connectée au bus interne du processeur. De plus, le décodeur dispose d'une micro-opération pour lire un octet depuis la PIQ et le copier ailleurs dans le chemin de données. Ainsi, l'instruction d'addition entre le registre AX et une constante immédiate était composée de plusieurs micro-opérations : une qui lisait la constante immédiate depuis la PIQ, une seconde qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la PIQ, pour la lire. Il lisait alors cet octet et déterminait s'il contenait une instruction complète ou non. Si c'est une instruction complète, il la décodait et l'exécutait, puis passait à l'instruction suivante. Sinon, il lit un second octet depuis la PIQ et continue le décodage. Là encore, le décodeur vérifie s'il a une instruction complète, et lit un troisième octet si besoin, puis rebelote avec un quatrième octet lu, etc.
Un circuit appelé le ''loader'' synchronisait le décodeur d'instruction et la PIQ. Il fournissait deux bits : un premier bit pour indiquer que le premier octet d'une instruction était disponible dans la PIQ, un second bit pour le second octet. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le signal ''Run Next Instruction'' entrainait la lecture d'une nouvelle instruction, d'un premier octet, qui était alors dépilé de la PIQ. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'', un cycle avant que l'instruction en cours ne termine. Le ''loader'' réagissait en préchargeant l'octet suivant. En clair, ce signal permettait de précharger l'instruction suivante avec un cycle d'avance, si celle-ci n'était pas déjà dans la PIQ.
Le décodeur d'instruction dispose aussi de trois micro-opérations SUSP, FLUSH, and CORR, qui agissent sur la PIQ. La micro-opération SUSP stoppe le préchargement des instructions dans la ''Prefetch input queue''. La micro-opération FLUSH réinitialise la PIQ et le préchargement reprend comme avant. Elle sert dans diverses situations. Par exemple, la PIQ était réinitialisée lors du passage du mode réel au mode protégé, et réciproquement. Quelques instructions sont décodées entre les deux modes, ce qui fait que l'on doit vider la PIQ entre les deux modes. L'instruction CORR sert pour gérer les branchements, et expliquer pourquoi demande une section complète, que voici.
===Les problèmes liés aux branchements===
Les branchements posent des problèmes avec la PIQ : si un branchement est pris, toutes les instructions préchargées après sont invalides. Pour éviter cela, la PIQ est réinitialisée quand le processeur détecte un branchement. Le microcode utilise pour cela la micro-opération qui réinitialise la PIQ. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la PIQ quand une interruption survient, avant de la traiter.
La PIQ précharge les instructions séquentiellement, une après l'autre. Pour savoir où elle en est rendue, elle devrait mémoriser l'adresse de la prochaine instruction à précharger dans un '''registre de préchargement''' dédié. Il est censé être séparé du ''program counter'', mais ce n'est pas la solution qui a été utilisée par les ingénieurs d'Intel. À la place, le registre de préchargement remplace le ''program counter''. Après tout, le registre de préchargement n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance.
Mais cela pose problème, avec les branchements. Par exemple, certains branchements relatifs demandent de connaitre la véritable valeur du ''program counter'', pas celle calculée en avance. Idem avec les instructions d'appel de fonction, qui demandent de sauvegarder l'adresse de retour exacte, donc le vrai ''program counter''. De telles situations demandent de connaitre la valeur réelle du ''program counter'', celle sans préchargement. Pour cela, le décodeur d'instruction dispose d'une instruction pour reconstituer le ''program counter'', à savoir corriger le ''program coutner'' et éliminer l'effet du préchargement.
La micro-opération de correction se contente de soustraire le nombre d'octets préchargés au registre de préchargement. Le nombre d'octets préchargés est déduit à partir des deux pointeurs intégré à la FIFO, qui indiquent la position du premier et du dernier octet préchargé. Le bit qui indique si la FIFO est vide était aussi utilisé. Les deux pointeurs sont lus depuis la FIFO, et sont envoyés à un circuit qui détermine le nombre d'octets préchargés. Sur le 8086, ce circuit était implémenté non pas par un circuit combinatoire, mais par une mémoire ROM équivalente. La petite taille de la FIFO faisait que les pointeurs étaient très petits et la ROM l'était aussi.
===L'interaction avec le code automodifiant===
Un autre défaut est que la ''Prefetch input queue'' se marie assez mal avec du code auto-modifiant. Un code auto-modifiant est un programme qui se modifie lui-même, en remplaçant certaines instructions par d'autres, en en retirant, en en ajoutant, lors de sa propre exécution. De tels programmes sont rares, mais la technique était utilisée dans quelques cas au tout début de l'informatique sur des ordinateurs rudimentaires. Ceux-ci avaient des modes d'adressages tellement limités que gérer des tableaux de taille variable demandait d'utiliser du code auto-modifiant pour écrire des boucles.
Le problème avec la ''Prefetch input queue'' survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans la ''Prefetch input queue'' sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas est quelque peu complexe. Il faut en effet vider la ''Prefetch input queue'' si le cas arrive, ce qui demande d'identifier les écritures qui écrasent des instructions préchargées. C'est parfaitement possible, mais demande de transformer la ''Prefetch input queue'' en une mémoire hybride, à la fois mémoire associative et mémoire FIFO. Cela ne vaut que très rarement le coup, aussi les ingénieurs ne s’embêtent pas à mettre ce correctif en place, le code automodifiant est alors buggé.
==Les processeurs 286 et 386 : le découplage du décodage==
Les processeurs 286, 386 et 486 avaient des possibilités de pipeline plus élaborées, qui dépassaient l'usage d'une ''Prefetch Input Queue''. Cependant, ce pipeline est fortement contrarié à cause d'un point : il n'y a pas de mémoire cache sur ces processeurs. La conséquence est que le processeur ne peut pas lire une instruction en même temps qu'il accède à des données. Le pipeline fonctionne donc bien si le processeur n'exécute pas d'accès mémoire, ou que ceux-ci sont peu fréquents. Le CPU peut alors précharger des instructions dans la PIQ, et le pipeline fonctionne. Mais si les accès mémoire sont trop nombreux, le pipeline n'est presque pas utilisé, car il n'y a pas assez d'instructions dans la PIQ.
===La ''Decoded Instruction Queue''===
L'Intel 286 et 386 ont séparé l'étage de décodage et d'exécution, en plus de rajouter des étages pour le calcul d'adresse. Ils avaient un pipeline à 3-6 étages : 3 pour les instructions classiques, 6 pour les instructions mémoire. Il avait un pipeline ''Fetch-Decode-Exec'' pour les instructions classiques, auxquelles il fallait rajouter 3 cycles supplémentaires pour les accès mémoire. Les 3 cycles supplémentaires s'occupaient de la segmentation, de la pagination, et de l'accès au cache lui-même.
Pour cela, les processeurs 286 et 386 ajoutaient une mémoire FIFO en sortie de l'unité de décodage. Du moins, c'est ainsi que l'on peut présenter les choses pour faire simple. Mais je rappelle que les CPU Intel de l'époque avaient un décodeur hybride, mélangeant un séquenceur câblé et un séquenceur microcodé. Et la FIFO était en quelque sorte en plein milieu du décodeur, avant le microcode, mais après le reste.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel 286, microarchitecture.]]
Pour rappel, les CPU 286/386/486 avaient un décodeur coupé en deux : un prédécodeur alimentait un séquenceur microcodé. Le prédécodeur peut décoder des instructions simples, et peut envoyer les autres instructions au microcode. Le prédécodeur est appelé la ''Group Decode ROM'', bien que ce ne soit pas une ROM. Il fournit 15 signaux qui configurent le séquenceur et disent si le décodage doit utiliser ou non le microcode. Il permet aussi de configurer le microcode pour gérer les différents modes d'adressage, ou encore de configurer les circuits câblés en aval du microcode.
Les instructions qui s’exécutent en un seul cycle d'horloge sont décodés sans utiliser le microcode. Leur opcode et les noms de registre sont simplement extraits par le prédécodeur, il n'y a rien de plus à faire. Le prédécodeur les envoie à la file de micro-opérations, et elles sont immédiatement exécutées dans le chemin de données. Les instructions microcodées passent aussi par cette file, mais elles sont envoyés au microcode en sortant de la file. La file en question n'est donc pas tout à fait une file de micro-opérations, ni une file d'instruction. C'est une sorte d'intermédiaire entre les deux, qui peut mémoriser soit des micro-opérations basiques, soit des instructions partiellement décodées à destination du microcode.
[[File:80386DX arch.png|centre|vignette|upright=3|Microarchitecture du 386.]]
Le résultat est un pipeline très simpliste et très peu efficace. Le pipeline fonctionne avec des instructions qui ne passent pas par le microcode. Mais pour les instructions microcodées, le pipeline stalle, il se bloque. Le décodeur, qui sert d'unité d'émission, gèle le pipeline quand il exécute une instruction multicycle. Et cela vaut pour les multiplications et divisions, mais aussi pour tout accès mémoire, instruction ''load-op'' inclues. En pratique, le pipeline n'était presque pas utilisé...
Tout cela montre la difficulté d'intégrer un pipeline sur un processeur microcodé. C'est parfaitement possible en pratique, et nous verrons de nombreux exemples dans la suite. Un chapitre entier sera consacré aux processeurs superscalaires x86, qui ont des pipelines particulièrement complexes. Cependant, le 386 et le 486 avaient peu de transistors et devaient microcoder la plupart de leurs instructions. Cela s'est arrangé avec le temps, notamment avec l'intégration de circuits multiplieur/diviseurs, l'augmentation du nombre de registres, et d'autres optimisations.
===L'''early start'' des CPU Intel 386===
Le 386 avait un système de contournement très rudimentaire, bien différent de tout ce qu'on a vu précédemment. Le contournement était appelé l'''early start'', et faisait du contournement uniquement pour les calculs d'adresse. L'idée est de fournir en avance les opérandes des calculs d'adresse.
Les calculs d'adresse sont réalisés soit par les instructions mémoire, soit par les instructions ''load-op''. Ces instructions ont besoin d'une adresse, éventuellement d'un indice et/ou d'un décalage. Et l'adresse, l'indice ou le décalage peut être calculé par l'instruction qui précède l'accès mémoire. Dans cette situation bien précise, un système de contournement permet de démarrer l'instruction mémoire/''load-op'' un cycle en avance.
Un problème est que l'implémentation était buguée, ce qui a donné naissance au '''''POPAD bug'''''. Après une instruction POPAD, le registre EAX est modifié. Si l'instruction suivante souhaite utiliser ce registre pour un calcul d'adresse, il est possible qu'elle ne voit pas la valeur d'EAX correcte.
==L'Intel 486 : un pipeline fortement couplé==
Les processeurs précédents avaient des pipelines faiblement couplés, ce qui était un moyen de compenser l'absence de mémoire cache et d'architecture Harvard. De tels processeurs ne pouvaient pas charger une instruction et lire/écrire une donnée en même temps. Un pipeline faiblement couplé était assez flexible pour fonctionner dans un tel environnement. Le résultat était loin du maximum théorique qu'on aurait pu obtenir sans ces contraintes, mais le gain en performance était là. Le 486 est passé à un pipeline fortement couplé, avec des étages séparés par des registres.
===Le passage à un pipeline fortement couplé===
Le passage à un pipeline fortement couplé a été rendu possible, ou du moins accompagné, par de nombreux changement dans l'architecture du 486.
La première optimisation est que les opérations basiques se font en un seul cycle d'horloge, pas deux. Les opérations simples en question sont les additions/soustractions, les opérations bit à bit et les décalages. Du moins, à condition qu'elles manipulent uniquement des registres ou des constantes immédiates. Les opérations ''load-op'' ne sont pas concernées, elles stallent le pipeline. Pour le dire autrement : l'étage d'exécution passe à un seul cycle d'horloge, ce qui permet une meilleure utilisation du pipeline et réduit le nombre de stalls.
Une seconde optimisation est l'intégration d'une mémoire cache dans le processeur. Il n'y avait pas encore de cache séparé pour les instructions et les données. Mais le cache était multiport, ce qui permettait de lire/écrire une donnée en chargeant une instruction en même temps. Il n'y a plus besoin de ''prefetch input queue'', et c'est pour cette raison qu'elle a disparue sur le 486.
Pour les instructions simples, sans accès mémoire, le pipeline faisait seulement 4 à 5 étages :
* un étage pour le ''prefetch'' dans la PIQ ;
* un étage de décodage/prédécodage ;
* un étage pour le microcode ;
* un étage pour l'unité de calcul entière ;
* un étage pour l’enregistrement dans les registres.
Les multiplications/divisions et autres opérations complexes pouvaient rester plusieurs cycles dans l'unité de calcul. Heureusement, c'était des instructions microcodées. Le microcode se chargeait de bloquer les unités de chargement et décodage pendant ce temps, il agissait en quelque sorte comme une sorte d'unité d'émission. Les instructions flottantes ont elles un pipeline plus long, sans possibilité d'émettre des instructions entières en parallèle.
Pour les instructions avec un accès mémoire, les choses sont plus compliquées. Pour simplifier, il faut rajouter trois étages, dont deux pour la mémoire virtuelle : un pour la segmentation, un autre pour la pagination, un dernier pour l'accès au cache. Ils sont insérés entre l'étage pour l'unité de calcul entière et celui d'enregistrement dans les registres. L'étage avec l'ALU entière est utilisé partiellement pour les calculs d'adresse de type Base+indice, il est facultatif si le mode d'adressage n'utilise pas d'indice. Le résultat est donc :
* un étage pour le ''prefetch'' dans la PIQ ;
* un étage de décodage/prédécodage ;
* un étage pour le microcode ;
* un étage pour l'unité de calcul entière si mode adressage indicé ;
* ''un étage pour la segmentation'' ;
* ''un étage pour la pagination'' ;
* ''un étage pour l'accès au cache ou à la mémoire RAM'' ;
* un étage pour l’enregistrement dans les registres.
Un point important est que 486 intègre le contournement des lectures, à savoir qu'une donnée lue peut immédiatement être utilisée par l'ALU entière et/ou la FPU au cycle suivant. Cela permet de simplifier l'implémentation des instructions ''load-op'', et améliore les performances en général. Par contre, l'optimisation d'''early start'' du 386 n'est pas implémentée, car trop compliquée.
===Les optimisations du bus mémoire sur le 486===
L'intégration du cache a été complétée par de nombreuses optimisations de l'interface mémoire. Par exemple, le processeur peut effectuer des accès en rafale, chose que les EDO-DRAM de l'époque supportaient. Il avait aussi un ''write buffer'' qui mettait en attente 4 écritures, en sortie du cache, dans l'interface mémoire elle-même. Lors d'une écriture, le processeur écrivait dans le cache et dans ce ''write buffer'', puis continuait son travail sans attendre que l'écriture soit faite en mémoire RAM. Les écritures sont faites en mémoire RAM quand le bus mémoire est libre. Le ''write buffer'' peut mettre 4 écritures en attente, mais le processeur stalle à la cinquième écriture.
Le processeur pouvait effectuer des lectures, même si des écritures sont en attente dans le ''write buffer''. Mais il faut certaines conditions pour que cela ne cause aucun problème. Un processeur moderne ferait du réacheminement lecture sur écriture, à savoir que l'on comparerait la lecture avec les écritures en attente. Si l'adresse lu correspond à une adresse écrite, alors une dépendance est détectée. Mais le 486 utilisait une méthode plus simple, moins performante, mais qui utilise moins de circuits.
A la place, la lecture n'est exécutée en avance que si les écritures en attente sont des succès de cache. Je rappelle que le cache est de type ''write through''. Si les écritures sont des succès de cache, cela veut dire que la donnée a déjà été écrite dans le cache, mais qu'elle attend de l'être en mémoire RAM. Faire une lecture en mémoire implique que celle-ci est un défaut de cache. Le fait d'avoir une lecture en défaut de cache et des écritures en succès de cache implique que la lecture n’accède pas aux données écrites. Cette contrainte garantit que la lecture n'a aucune dépendance avec les écritures en attente. Pas besoin de tester les dépendances, on a juste besoin de mémoriser pour chaque écriture si elle provient d'un succès ou défaut de cache, un bit suffit.
Par contre, faire une lecture en avance ainsi ne peut être fait qu'une seule fois. Les écritures en attente doivent être vidées en mémoire RAM pour que l'on puisse faire une nouvelle lecture en avance. C'est pour éviter qu'une seconde lecture mène à des dépendances mémoire particulières. Pour cela, quand une lecture est exécutée en avance, les écritures en attente sont toutes marquées comme des défauts de cache. D'autres circonstances demandent de marquer les écritures en attente comme des défauts de cache. Par exemple, toute invalidation du cache demande de le faire.
: Vous noterez que si le cache est désactivé, le ''write buffer'' est désactivé, lui aussi.
[[File:80486DX2 arch.svg|centre|vignette|upright=3|486 DX2 microarchitecture.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission dans l'ordre des instructions
| prevText=L'émission dans l'ordre des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}{{autocat}}
</noinclude>
qbghaeq0btjm7t5v9q5likm3ecoamn6
774775
774624
2026-10-11T01:28:51Z
Mewtow
31375
/* Les optimisations du bus mémoire sur le 486 */
774775
wikitext
text/x-wiki
Les chapitres précédents ont été assez chargés, et nous ont appris comment fonctionne un pipeline simple. Découper un processeur en étape, gérer les branchements, découpler les étages avec des FIFOs, gérer les dépendances de données, implémenter le contournement. De nombreux processeurs RISC intègrent un pipeline similaire à celui décrit dans les chapitres précédents, souvent implémenté afin d'avoir de bonnes performances. L'implémentation est claire, classique, assez propre. En comparaison, les premiers processeurs Intel avaient des pipeline bien différents, suffisamment pour les aborder dans un chapitre dédié.
Par premiers CPU Intel, nous voulons parler des processeurs avant le Pentium. Il s'agit du 8086, du 286, du 386 et du 486. Le Pentium est un peu particulier, car c'est un processeur superscalaire, ce qui fait qu'on n'en parlera pas ici. Les premiers CPU Intel avaient un pipeline très simple, assez semblable à un pipeline ''Fetch-Decode-Exec''. Cependant, l'implémentation de ce pipeline a été compliquée par plusieurs point de blocage. Le premier est le fait que ce soit des processeurs CISC, avec beaucoup d'instructions microcodées. Le second est l'absence de mémoire cache intégrée (sauf pour le 486) et le fait que ce ne soient pas des architectures Harvard. Il est intéressant de voir les premiers CPU Intel pour voir comment les processeurs CISC se sont débrouillés pour surmonter ces problèmes.
==Les pipelines faiblement et fortement couplés==
L'évolution des processeurs Intel a été une lente transition d'un type de pipeline vers un autre. Le 8086, le 286 et le 386 utilisaient des '''pipelines faiblement couplés''', alors que le 486 utilisait un '''pipeline fortement couplé'''. Les pipelines fortement couplés équilibrent leurs étages au mieux, afin que chaque étage prennent approximativement le même temps. Le résultat est que chaque étage fait son travail en un cycle d'horloge, pas plus. À l'opposé, les pipelines faiblement couplés ont des étages inégaux, dont certains font plusieurs cycles d'horloge.
Précisons que la définition n'interdit pas d'avoir des instructions multicycles. Les accès mémoire et quelques instructions complexes peuvent passer plusieurs cycles dans le même étage. Mais pour les instructions simples, à savoir les additions/soustractions/décalages/opérations bit à bit ; qui ne font pas d'accès mémoire et agissent uniquement sur des registres. Elles doivent idéalement s'exécuter en un seul cycle sur un pipeline fortement couplé. De même, il est possible d'avoir des instructions simples qui prennent plusieurs cycles, à condition qu'elles soient pipelinées. Par exemple, si une addition prend deux cycles, alors elle s'exécute en deux étapes qui peuvent exécuter deux calculs différents en même temps : un dans le premier étage, un autre dans le second étage.
Pour donner un exemple, sur le 286 et le 386, beaucoup d'opérations basiques prenaient plusieurs cycles. Les additions, soustractions, opérations bit à bit et décalages prenaient toutes 2 cycles d'horloge. Et elles n'étaient pas pipelinées, ce qui fait que l'unité de calcul bloquait le pipeline quand une instruction s'exécutait dedans. Et il en est de même avec l'étage de décodage : il pouvait décoder une instructions simples en un seul cycle d'horloge, mais les instructions complexes prenaient plus de temps à décoder.
Les pipelines fortement couplés peuvent exécuter plusieurs instructions en même temps sans trop de problèmes, mais il est fréquent que certains étages soient inutilisés, même dans des circonstances optimales. Au contraire, un pipeline fortement couplé s'approche plus facilement de son optimum théorique. Un pipeline fortement couplé à N étages s'approchera des N instructions exécutées en même temps, alors qu'un pipeline faiblement couplé aura tendance à avoir des étages inutilisés.
===Les pipelines à registres et à FIFO===
Dans les implémentations les plus simples, les pipelines fortement couplés utilisent des registres entre chaque étage, alors que les faiblement couplés n'ont pas de choix : ils doivent utiliser des FIFOs entre chaque étage. L'implémentation est alors plus simple sur les pipelines fortement couplés, mais cela a un prix : au moindre problème (dépendance de données, branchements), le pipeline stalle pour éviter tout problème. Un pipeline faiblement couplé n'a pas ce problème, certains étages peuvent prendre de l'avance au lieu de se bloquer.
Et cette flexibilité en cas de stall explique que le 286 et le 386 utilisaient des pipelines faiblement couplés, comme nous allons le voir. Sans compter que l'implémentation était aussi plus simple. Avoir des étages déséquilibrés permettait une implémentation simple. Il suffisait de placer une file d'instructions et une file de micro-opérations pour obtenir un pipeline ''fetch-decode-exec''. Pas besoin de découper le décodeur pour le pipeliner, pas besoin de pipeliner l'ALU, on les laisse intacts.
Mais il s'agit là des implémentations les plus simples. De nos jours, de nombreux pipelines fortement couplés utilisent des FIFOs pour découpler leurs étages, ce qui permet d'avoir le meilleur des deux mondes. L'usage de files d'instruction et de files de micro-opérations est courante sur les processeurs haute performance modernes. Mais ils combinent celles-ci avec des pipelines équilibrés, où chaque étage fait un cycle d'horloge. Les deux détails, équilibrage des étages et découplage avec des FIFOs, entrainent un gain de performance, qui s'additionne quand on les combine.
==La ''prefetch input queue'' : une file d'instruction==
Les premiers processeurs Intel intégraient une file d’instruction quelque peu particulière, appelée la '''''Prefetch Input Queue''''', terme abrévié en PIQ. Elle a été utilisée sur tous les processeurs avant le Pentium, à savoir les processeurs Intel 8 et 16 bits, ainsi que sur le 286 et le 386.
Avec la PIQ, pendant qu'une instruction est en cours de décodage/exécution, le processeur précharge les instructions suivantes, jusqu'à une limite de 4/6/16/32 octets (la limite dépend du processeur). La ''prefetch input queue'' permet donc d'implémenter une forme de pipeline ''Fetch + Decode/Exec''. Mais il est aussi possible de dire qu'il s'agit d'une technique de préchargement d'instructions, vu qu'elle permet de précharger plusieurs instructions si les circonstances le permettent. La différence entre les deux est assez subtile et les frontières sont assez floues.
La PIQ permet de précharger l'instruction suivante à condition qu'aucun autre accès mémoire n'ait lieu. Les CPU Intel avant le 486 n'avaient pas de cache, ni d'architecture Harvard. La conséquence est qu'il n'est pas possible de lire une instruction et faire un accès mémoire en même temps. Si l'instruction en cours d'exécution effectue des accès mémoire, ceux-ci sont prioritaires sur tout le reste, le préchargement de l'instruction suivante est mis en pause. Le préchargement n'est possible que si l'instruction en cours d'exécution ne travaille que sur des registres.
La PIQ faisait 6 octets sur le 8086 et le 286, puis est montée à 16 octets sur le 386, pour atteindre 32 octets sur le 486. Le 8088, une version simplifiée du 8086, avait une PIQ de 4 octets seulement. La taille idéale de la FIFO dépend de nombreux paramètres. On pourrait croire que plus elle peut contenir d'instructions, mieux c'est, mais ce n'est pas le cas, pour des raisons que nous allons expliquer dans quelques paragraphes.
Vous remarquerez que j'ai exprimé la capacité de la PIQ en octets et non en instructions. La raison est que la PIQ s'est adaptée au fait que le jeu d'instruction x86 a des instructions de taille variable. Ce n'est donc pas tellement une file d'instruction au sens strict. Le jeu d'instruction x86 a des instructions dont la taille varie entre un octet et 15 octets. Vous noterez que la PIQ est mieux exploitée quand les instructions sont courtes, ce qui permet de précharger plus d'instructions.
===L'interaction avec le décodage d'instruction===
La PIQ dispose d'un port d'écriture sur lequel les instructions préchargées sont envoyées, et un port de lecture où le décodeur lit la prochaine instruction à décoder.
La PIQ du 8088 était la plus simple, niveau implémentation. Sur le 8088, tout faisait un octet : le bus de données, les ports de la PIQ, et j'en passe. La PIQ du 8088 était composée de 4 registres de 8 bits, avec un port d'écriture de 8 bits pour précharger les instructions octet par octet, et un port de lecture de 8 bits pour alimenter le décodeur. En clair, tout faisait 8 bits.
Le 8086 était quant à lui un processeur 16 bits, ce qui changeait un peu les choses. Sa PIQ était adaptée à un bus de données de 16 bits, à savoir qu'elle mémorisait ses 6 octets avec 3 registres de 16 bits. Le préchargement des instructions se faisait deux octets à la fois, un registre à la fois, grâce à un port d'écriture de 16 bits. Un détail important est que le préchargement était aligné sur 16 bits en mémoire. Si l'instruction à charger n'était pas alignée sur 16 bits, elle doit être chargée en plusieurs fois. Par exemple, une instruction de 2 octets non-alignée sera chargée en deux fois : un premier accès qui charge deux octets, dont un est oublié, et un second accès pour l'octet restant, dont l'autre octet aussi est oublié.
Par contre, le décodage se faisait un octet à la fois. Le décodeur lit un octet, puis éventuellement le suivant, et ainsi de suite jusqu'à atteindre la fin de l'instruction. Le port de lecture faisait ainsi un octet sur le 8086, comme sur le 8088. Le port de lecture incorporait pour cela un multiplexeur, qui sélectionnait l'octet adéquat dans un registre 16 bits. L'avantage est que le préchargement est plus efficace, car il préchargeait plus d'octets par cycle. Vu que la PIQ ne peut pas précharger d'instruction à chaque cycle, du fait des accès mémoire, le fait de précharger plus à chaque accès compense.
[[File:80186 arch.png|centre|vignette|700px|upright=2|Architecture du 8086, du 80186 et de ses variantes.]]
Le 386 était un processeur 32 bits, ce qui fait que sa PIQ contenait 4 registres de 32 bits et un port d'écriture de 32 bits. Et le port de lecture pour le décodeur d'instruction faisait lui aussi 32 bits. Il était cependant capable de gérer des instructions de 8 et 16 bits. Il pouvait dépiler entre 1 et 4 octets dans la PIQ, suivant la taille de l'instruction voulue. De plus, les instructions préchargées n'étaient pas parfaitement alignées sur 32 bits : une instruction pouvait être à cheval sur deux registres 32 bits. Le processeur incorporait donc des circuits d'alignement, semblables à ceux utilisés pour gérer des instructions de longueur variable avec un registre d'instruction.
===Les signaux de commande entre décodeur et PIQ===
Les instructions varient entre 1 et 15 octets, mais tous ne sont pas utiles au décodeur d'instruction. Le décodeur d'instruction n'a besoin que de deux octets : l'opcode et l'octet Mod/RM qui précise le mode d'adressage et les registres. Les octets de préfixe sont aussi utiles au décodeur, mais la plupart des instructions n'en ont pas. Les constantes immédiates, adresses absolues et décalages, ne passent pas par le décodeur d'instruction. Ils sont lus depuis la PIQ, mais sont copiées dans des registres dédiés, sous la commande du décodeur d'instruction. En clair, le décodeur a besoin de lire deux octets maximum depuis la PIQ, avant de passer à l’instruction suivante. Les autres octets étaient envoyés ailleurs, typiquement dans le chemin de données.
Par exemple, prenons le cas d'une addition entre le registre AX et une constante immédiate. L'opcode était envoyé au décodeur, mais pas la constante immédiate. Elle était lue octet par octet et mémorisée dans un registre temporaire placé en entrée de l'ALU. Idem avec les adresses immédiates, qui étaient envoyées dans un registre d'interfaçage mémoire sans passer par le décodeur d'instruction. Pour cela, la PIQ était connectée au bus interne du processeur. De plus, le décodeur dispose d'une micro-opération pour lire un octet depuis la PIQ et le copier ailleurs dans le chemin de données. Ainsi, l'instruction d'addition entre le registre AX et une constante immédiate était composée de plusieurs micro-opérations : une qui lisait la constante immédiate depuis la PIQ, une seconde qui commande l'ALU et fait le calcul.
Le décodeur devait attendre que qu'un moins un octet soit disponible dans la PIQ, pour la lire. Il lisait alors cet octet et déterminait s'il contenait une instruction complète ou non. Si c'est une instruction complète, il la décodait et l'exécutait, puis passait à l'instruction suivante. Sinon, il lit un second octet depuis la PIQ et continue le décodage. Là encore, le décodeur vérifie s'il a une instruction complète, et lit un troisième octet si besoin, puis rebelote avec un quatrième octet lu, etc.
Un circuit appelé le ''loader'' synchronisait le décodeur d'instruction et la PIQ. Il fournissait deux bits : un premier bit pour indiquer que le premier octet d'une instruction était disponible dans la PIQ, un second bit pour le second octet. Le ''loader'' recevait aussi des signaux de la part du décodeur d'instruction. Le signal ''Run Next Instruction'' entrainait la lecture d'une nouvelle instruction, d'un premier octet, qui était alors dépilé de la PIQ. Le décodeur d'instruction pouvait aussi envoyer un signal ''Next-to-last (NXT)'', un cycle avant que l'instruction en cours ne termine. Le ''loader'' réagissait en préchargeant l'octet suivant. En clair, ce signal permettait de précharger l'instruction suivante avec un cycle d'avance, si celle-ci n'était pas déjà dans la PIQ.
Le décodeur d'instruction dispose aussi de trois micro-opérations SUSP, FLUSH, and CORR, qui agissent sur la PIQ. La micro-opération SUSP stoppe le préchargement des instructions dans la ''Prefetch input queue''. La micro-opération FLUSH réinitialise la PIQ et le préchargement reprend comme avant. Elle sert dans diverses situations. Par exemple, la PIQ était réinitialisée lors du passage du mode réel au mode protégé, et réciproquement. Quelques instructions sont décodées entre les deux modes, ce qui fait que l'on doit vider la PIQ entre les deux modes. L'instruction CORR sert pour gérer les branchements, et expliquer pourquoi demande une section complète, que voici.
===Les problèmes liés aux branchements===
Les branchements posent des problèmes avec la PIQ : si un branchement est pris, toutes les instructions préchargées après sont invalides. Pour éviter cela, la PIQ est réinitialisée quand le processeur détecte un branchement. Le microcode utilise pour cela la micro-opération qui réinitialise la PIQ. Les interruptions posent le même genre de problèmes. Il faut impérativement vider la PIQ quand une interruption survient, avant de la traiter.
La PIQ précharge les instructions séquentiellement, une après l'autre. Pour savoir où elle en est rendue, elle devrait mémoriser l'adresse de la prochaine instruction à précharger dans un '''registre de préchargement''' dédié. Il est censé être séparé du ''program counter'', mais ce n'est pas la solution qui a été utilisée par les ingénieurs d'Intel. À la place, le registre de préchargement remplace le ''program counter''. Après tout, le registre de préchargement n'est qu'un ''program counter'' ayant pris une avance de quelques cycles d'horloge, qui a été incrémenté en avance.
Mais cela pose problème, avec les branchements. Par exemple, certains branchements relatifs demandent de connaitre la véritable valeur du ''program counter'', pas celle calculée en avance. Idem avec les instructions d'appel de fonction, qui demandent de sauvegarder l'adresse de retour exacte, donc le vrai ''program counter''. De telles situations demandent de connaitre la valeur réelle du ''program counter'', celle sans préchargement. Pour cela, le décodeur d'instruction dispose d'une instruction pour reconstituer le ''program counter'', à savoir corriger le ''program coutner'' et éliminer l'effet du préchargement.
La micro-opération de correction se contente de soustraire le nombre d'octets préchargés au registre de préchargement. Le nombre d'octets préchargés est déduit à partir des deux pointeurs intégré à la FIFO, qui indiquent la position du premier et du dernier octet préchargé. Le bit qui indique si la FIFO est vide était aussi utilisé. Les deux pointeurs sont lus depuis la FIFO, et sont envoyés à un circuit qui détermine le nombre d'octets préchargés. Sur le 8086, ce circuit était implémenté non pas par un circuit combinatoire, mais par une mémoire ROM équivalente. La petite taille de la FIFO faisait que les pointeurs étaient très petits et la ROM l'était aussi.
===L'interaction avec le code automodifiant===
Un autre défaut est que la ''Prefetch input queue'' se marie assez mal avec du code auto-modifiant. Un code auto-modifiant est un programme qui se modifie lui-même, en remplaçant certaines instructions par d'autres, en en retirant, en en ajoutant, lors de sa propre exécution. De tels programmes sont rares, mais la technique était utilisée dans quelques cas au tout début de l'informatique sur des ordinateurs rudimentaires. Ceux-ci avaient des modes d'adressages tellement limités que gérer des tableaux de taille variable demandait d'utiliser du code auto-modifiant pour écrire des boucles.
Le problème avec la ''Prefetch input queue'' survient quand des instructions sont modifiées immédiatement après avoir été préchargées. Les instructions dans la ''Prefetch input queue'' sont l'ancienne version, alors que la mémoire RAM contient les instructions modifiées. Gérer ce genre de cas est quelque peu complexe. Il faut en effet vider la ''Prefetch input queue'' si le cas arrive, ce qui demande d'identifier les écritures qui écrasent des instructions préchargées. C'est parfaitement possible, mais demande de transformer la ''Prefetch input queue'' en une mémoire hybride, à la fois mémoire associative et mémoire FIFO. Cela ne vaut que très rarement le coup, aussi les ingénieurs ne s’embêtent pas à mettre ce correctif en place, le code automodifiant est alors buggé.
==Les processeurs 286 et 386 : le découplage du décodage==
Les processeurs 286, 386 et 486 avaient des possibilités de pipeline plus élaborées, qui dépassaient l'usage d'une ''Prefetch Input Queue''. Cependant, ce pipeline est fortement contrarié à cause d'un point : il n'y a pas de mémoire cache sur ces processeurs. La conséquence est que le processeur ne peut pas lire une instruction en même temps qu'il accède à des données. Le pipeline fonctionne donc bien si le processeur n'exécute pas d'accès mémoire, ou que ceux-ci sont peu fréquents. Le CPU peut alors précharger des instructions dans la PIQ, et le pipeline fonctionne. Mais si les accès mémoire sont trop nombreux, le pipeline n'est presque pas utilisé, car il n'y a pas assez d'instructions dans la PIQ.
===La ''Decoded Instruction Queue''===
L'Intel 286 et 386 ont séparé l'étage de décodage et d'exécution, en plus de rajouter des étages pour le calcul d'adresse. Ils avaient un pipeline à 3-6 étages : 3 pour les instructions classiques, 6 pour les instructions mémoire. Il avait un pipeline ''Fetch-Decode-Exec'' pour les instructions classiques, auxquelles il fallait rajouter 3 cycles supplémentaires pour les accès mémoire. Les 3 cycles supplémentaires s'occupaient de la segmentation, de la pagination, et de l'accès au cache lui-même.
Pour cela, les processeurs 286 et 386 ajoutaient une mémoire FIFO en sortie de l'unité de décodage. Du moins, c'est ainsi que l'on peut présenter les choses pour faire simple. Mais je rappelle que les CPU Intel de l'époque avaient un décodeur hybride, mélangeant un séquenceur câblé et un séquenceur microcodé. Et la FIFO était en quelque sorte en plein milieu du décodeur, avant le microcode, mais après le reste.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel 286, microarchitecture.]]
Pour rappel, les CPU 286/386/486 avaient un décodeur coupé en deux : un prédécodeur alimentait un séquenceur microcodé. Le prédécodeur peut décoder des instructions simples, et peut envoyer les autres instructions au microcode. Le prédécodeur est appelé la ''Group Decode ROM'', bien que ce ne soit pas une ROM. Il fournit 15 signaux qui configurent le séquenceur et disent si le décodage doit utiliser ou non le microcode. Il permet aussi de configurer le microcode pour gérer les différents modes d'adressage, ou encore de configurer les circuits câblés en aval du microcode.
Les instructions qui s’exécutent en un seul cycle d'horloge sont décodés sans utiliser le microcode. Leur opcode et les noms de registre sont simplement extraits par le prédécodeur, il n'y a rien de plus à faire. Le prédécodeur les envoie à la file de micro-opérations, et elles sont immédiatement exécutées dans le chemin de données. Les instructions microcodées passent aussi par cette file, mais elles sont envoyés au microcode en sortant de la file. La file en question n'est donc pas tout à fait une file de micro-opérations, ni une file d'instruction. C'est une sorte d'intermédiaire entre les deux, qui peut mémoriser soit des micro-opérations basiques, soit des instructions partiellement décodées à destination du microcode.
[[File:80386DX arch.png|centre|vignette|upright=3|Microarchitecture du 386.]]
Le résultat est un pipeline très simpliste et très peu efficace. Le pipeline fonctionne avec des instructions qui ne passent pas par le microcode. Mais pour les instructions microcodées, le pipeline stalle, il se bloque. Le décodeur, qui sert d'unité d'émission, gèle le pipeline quand il exécute une instruction multicycle. Et cela vaut pour les multiplications et divisions, mais aussi pour tout accès mémoire, instruction ''load-op'' inclues. En pratique, le pipeline n'était presque pas utilisé...
Tout cela montre la difficulté d'intégrer un pipeline sur un processeur microcodé. C'est parfaitement possible en pratique, et nous verrons de nombreux exemples dans la suite. Un chapitre entier sera consacré aux processeurs superscalaires x86, qui ont des pipelines particulièrement complexes. Cependant, le 386 et le 486 avaient peu de transistors et devaient microcoder la plupart de leurs instructions. Cela s'est arrangé avec le temps, notamment avec l'intégration de circuits multiplieur/diviseurs, l'augmentation du nombre de registres, et d'autres optimisations.
===L'''early start'' des CPU Intel 386===
Le 386 avait un système de contournement très rudimentaire, bien différent de tout ce qu'on a vu précédemment. Le contournement était appelé l'''early start'', et faisait du contournement uniquement pour les calculs d'adresse. L'idée est de fournir en avance les opérandes des calculs d'adresse.
Les calculs d'adresse sont réalisés soit par les instructions mémoire, soit par les instructions ''load-op''. Ces instructions ont besoin d'une adresse, éventuellement d'un indice et/ou d'un décalage. Et l'adresse, l'indice ou le décalage peut être calculé par l'instruction qui précède l'accès mémoire. Dans cette situation bien précise, un système de contournement permet de démarrer l'instruction mémoire/''load-op'' un cycle en avance.
Un problème est que l'implémentation était buguée, ce qui a donné naissance au '''''POPAD bug'''''. Après une instruction POPAD, le registre EAX est modifié. Si l'instruction suivante souhaite utiliser ce registre pour un calcul d'adresse, il est possible qu'elle ne voit pas la valeur d'EAX correcte.
==L'Intel 486 : un pipeline fortement couplé==
Les processeurs précédents avaient des pipelines faiblement couplés, ce qui était un moyen de compenser l'absence de mémoire cache et d'architecture Harvard. De tels processeurs ne pouvaient pas charger une instruction et lire/écrire une donnée en même temps. Un pipeline faiblement couplé était assez flexible pour fonctionner dans un tel environnement. Le résultat était loin du maximum théorique qu'on aurait pu obtenir sans ces contraintes, mais le gain en performance était là. Le 486 est passé à un pipeline fortement couplé, avec des étages séparés par des registres.
===Le passage à un pipeline fortement couplé===
Le passage à un pipeline fortement couplé a été rendu possible, ou du moins accompagné, par de nombreux changement dans l'architecture du 486.
La première optimisation est que les opérations basiques se font en un seul cycle d'horloge, pas deux. Les opérations simples en question sont les additions/soustractions, les opérations bit à bit et les décalages. Du moins, à condition qu'elles manipulent uniquement des registres ou des constantes immédiates. Les opérations ''load-op'' ne sont pas concernées, elles stallent le pipeline. Pour le dire autrement : l'étage d'exécution passe à un seul cycle d'horloge, ce qui permet une meilleure utilisation du pipeline et réduit le nombre de stalls.
Une seconde optimisation est l'intégration d'une mémoire cache dans le processeur. Il n'y avait pas encore de cache séparé pour les instructions et les données. Mais le cache était multiport, ce qui permettait de lire/écrire une donnée en chargeant une instruction en même temps. Il n'y a plus besoin de ''prefetch input queue'', et c'est pour cette raison qu'elle a disparue sur le 486.
Pour les instructions simples, sans accès mémoire, le pipeline faisait seulement 4 à 5 étages :
* un étage pour le ''prefetch'' dans la PIQ ;
* un étage de décodage/prédécodage ;
* un étage pour le microcode ;
* un étage pour l'unité de calcul entière ;
* un étage pour l’enregistrement dans les registres.
Les multiplications/divisions et autres opérations complexes pouvaient rester plusieurs cycles dans l'unité de calcul. Heureusement, c'était des instructions microcodées. Le microcode se chargeait de bloquer les unités de chargement et décodage pendant ce temps, il agissait en quelque sorte comme une sorte d'unité d'émission. Les instructions flottantes ont elles un pipeline plus long, sans possibilité d'émettre des instructions entières en parallèle.
Pour les instructions avec un accès mémoire, les choses sont plus compliquées. Pour simplifier, il faut rajouter trois étages, dont deux pour la mémoire virtuelle : un pour la segmentation, un autre pour la pagination, un dernier pour l'accès au cache. Ils sont insérés entre l'étage pour l'unité de calcul entière et celui d'enregistrement dans les registres. L'étage avec l'ALU entière est utilisé partiellement pour les calculs d'adresse de type Base+indice, il est facultatif si le mode d'adressage n'utilise pas d'indice. Le résultat est donc :
* un étage pour le ''prefetch'' dans la PIQ ;
* un étage de décodage/prédécodage ;
* un étage pour le microcode ;
* un étage pour l'unité de calcul entière si mode adressage indicé ;
* ''un étage pour la segmentation'' ;
* ''un étage pour la pagination'' ;
* ''un étage pour l'accès au cache ou à la mémoire RAM'' ;
* un étage pour l’enregistrement dans les registres.
Un point important est que 486 intègre le contournement des lectures, à savoir qu'une donnée lue peut immédiatement être utilisée par l'ALU entière et/ou la FPU au cycle suivant. Cela permet de simplifier l'implémentation des instructions ''load-op'', et améliore les performances en général. Par contre, l'optimisation d'''early start'' du 386 n'est pas implémentée, car trop compliquée.
===Les optimisations du bus mémoire sur le 486===
L'intégration du cache a été complétée par de nombreuses optimisations de l'interface mémoire. Par exemple, le processeur peut effectuer des accès en rafale, chose que les EDO-DRAM de l'époque supportaient. Il avait aussi un ''write buffer'' qui mettait en attente 4 écritures, en sortie du cache, dans l'interface mémoire elle-même. Lors d'une écriture, le processeur écrivait dans le cache et dans ce ''write buffer'', puis continuait son travail sans attendre que l'écriture soit faite en mémoire RAM. Les écritures sont faites en mémoire RAM quand le bus mémoire est libre. Le ''write buffer'' peut mettre 4 écritures en attente, mais le processeur stalle à la cinquième écriture.
Le processeur pouvait effectuer des lectures, même si des écritures sont en attente dans le ''write buffer''. Mais il faut certaines conditions pour que cela ne cause aucun problème. Un processeur moderne ferait du réacheminement lecture sur écriture, à savoir que l'on comparerait la lecture avec les écritures en attente. Si l'adresse lu correspond à une adresse écrite, alors une dépendance est détectée. Mais le 486 utilisait une méthode plus simple, moins performante, mais qui utilise moins de circuits.
A la place, la lecture n'est exécutée en avance que si les écritures en attente sont des succès de cache. Je rappelle que le cache est de type ''write through''. Si les écritures sont des succès de cache, cela veut dire que la donnée a déjà été écrite dans le cache, mais qu'elle attend de l'être en mémoire RAM. Faire une lecture en mémoire implique que celle-ci est un défaut de cache. Le fait d'avoir une lecture en défaut de cache et des écritures en succès de cache implique que la lecture n’accède pas aux données écrites. Cette contrainte garantit que la lecture n'a aucune dépendance avec les écritures en attente. Pas besoin de tester les dépendances, on a juste besoin de mémoriser pour chaque écriture si elle provient d'un succès ou défaut de cache, un bit suffit.
Par contre, faire une lecture en avance ainsi ne peut être fait qu'une seule fois. Les écritures en attente doivent être vidées en mémoire RAM pour que l'on puisse faire une nouvelle lecture en avance. C'est pour éviter qu'une seconde lecture mène à des dépendances mémoire particulières. Pour cela, quand une lecture est exécutée en avance, les écritures en attente sont toutes marquées comme des défauts de cache. D'autres circonstances demandent de marquer les écritures en attente comme des défauts de cache. Par exemple, toute invalidation du cache demande de le faire.
: Vous noterez que si le cache est désactivé, le ''write buffer'' est désactivé, lui aussi.
[[File:80486DX2 arch.svg|centre|vignette|upright=3|486 DX2 microarchitecture.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures dataflow
| prevText=Les architectures dataflow
| next=LLes microarchitectures pour le x86 : haute performance
| nextText=Les microarchitectures pour le x86 : haute performance
}}{{autocat}}
</noinclude>
3i3iv73ummk2gvyz79wer6z5ijovue5
Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation
0
84530
774777
774246
2026-10-11T01:29:33Z
Mewtow
31375
774777
wikitext
text/x-wiki
Le chapitre précédent étudiait les processeurs x86 destinés aux PC fixe, au ''gaming'', aux serveurs, bref : à ce tout ce qui demande de la performance avant tout. De telles processeurs sont appelés des processeurs haute performance. Mais divers processeurs x86 visent eux, non pas la performance, mais surtout une consommation d'énergie réduite. Ils sont destinés aux PC portables, essentiellement. Cela ne veut pas dire qu'ils ne sont pas performants, mais qu'ils font un compromis différent : moins de performance, moins de consommation.
La première microarchitecture basse consommation d'Intel était celle des processeurs Atom, une gamme de processeurs basse consommation séparée des processeurs pour ''desktop''. Les processeurs Atom ont utilisé plusieurs microarchitectures succesives. La microarchitecture ''Bonnell'' et son ''die shrink'' ''Saltwell'' a été la première. C'était une microarchitecture sans exécution dans le désordre. Les microarchitectures qui suivent sont, dans l'ordre chronologique et en 2026 : ''Silvermont'', ''Goldmont'', ''Tremont'' et ''Gracemont''. Elles utilisent toutes l'exécution dans le désordre.
Outre les processeurs Atom, Intel utilise aussi ces microarchitectures basse consommation dans ses processeurs pour ''desktop''. De nos jours, les processeurs Intel utilisent les deux types de micro-architectures en même temps. Les CPU Intel modernes disposent de deux types de coeurs : les coeurs P et le coeurs E. Leur nom signifie "Performance" et "Efficient", qui trahissent leur but. Les coeurs P utilisent la microarchitecture de la lignée principale, qui est conçue pour la performance. Les coeurs E, quant à eux, utilisent les micro-architectures basse consommation de la lignée secondaire. LA technique est aussi utilisée par les processeurs ARM et quelques autres marques.
==Les microarchitectures basse consommation==
Qui dit basse consommation dit souvent : microarchitectures adaptées. Alors certes, certains modèles basse consommation utilisent les microarchitectures du chapitre précédent, et retirent des cœurs ou du cache. C'est une possibilité, mais elle n'est pas systématique. De nombreux processeurs basse consommation utilisent une microarchitecture adaptée, prévue pour consommer moins. Les microarchitectures de ce genre seront appelées, dans ce chapitre, des '''microarchitectures basse consommation'''.
Économiser de l'énergie correspond généralement à '''économiser des transistors'''. Les transistors ont tendance à consommer de l'énergie, et ce même quand ils ne changent pas d'état, à cause des courants de fuite (consommation statique). Mais il y a des contre-exemples pour lesquels utiliser du transistor d'un côté permet de faire des économies de l'autre. Tout est histoire de vases communicants, au final.
Une autre tendance est que les processeurs basse consommation tendent à '''moins spéculer'''. Ils ne font pas de d'exécution spéculative sur les dépendances mémoire, ne font pas de prédiction d'adresse ou de prédiction de valeur, ils n'émettent pas en avance les µops mais attendent sagement que leurs opérandes soient disponibles, etc. Avec cependant une exception de taille : la prédiction de branchement!
Les caches sont aussi fortement modifiés. Un processeur est surtout composé de mémoires caches de nos jours, ce qui fait que la majorité de la consommation d'énergie se fait dedans. Ils sont donc les premiers touchés pour faire des économies. La hiérarchie de caches est modifiée, la taille des caches change et des niveaux de cache peuvent disparaitre. Le préchargement est limité et ne précharge que quand il est certain de ne pas polluer le cache.
===L'exécution dans le désordre basse consommation===
Le ROB et les ''schedulers'' sont des mémoires associatives améliorées, ce qui fait qu'ils consomment beaucoup d'énergie. En conséquence, les toutes premières microarchitectures basse consommation étaient des '''processeurs ''in-order''''', à savoir qu'ils se débarrassaient de l’exécution dans le désordre. Les processeurs basse consommation ARM faisaient cela, de même que les processeurs MIPS ou autres destinés à l'embarqué. Chez Intel, les premiers processeurs Atom d'Intel n'utilisaient pas l'exécution dans le désordre, ce qui fait qu'il aura droit à sa propre section dédiée.
Mais la loi de Moore a changé la donne avec le temps. Même pour de la basse consommation, l'exécution dans le désordre est utilisée. Le gain en performance est tellement important que le ratio performance/consommation est amélioré. Quitte à gagner 250% de performances avec une consommation augmentée de 50%, autant en profiter, quitte à faire des économies ailleurs. Par contre, l'exécution dans le désordre est moins poussée. Le ROB est plus petit, de même que le ou les ''schedulers'', l'unité de renommage est moins performante, la ''store queue'' est plus petite. On met moins de transistors dans ces structures.
Et au passage, revenons sur le fait que les ''schedulers'' sont des mémoires associatives améliorées et consomment donc beaucoup d'énergie. Si le processeur utilise l'émission dans le désordre, il peut passer d'un ''scheduler'' centralisé à un ''scheduler'' décentralisé. Pour un nombre d'entrées identique, le cout en transistors est bien moindre, alors que le cout en performance est acceptable. Il est même possible d'aller encore plus loin et de remplacer les ''schedulers'' par des files de µops, pour passer d'une exécution dans le désordre avec plusieurs files de µops, sans ''schedulers''.
Le renommage de registres est systématiquement utilisé, si l'exécution dans le désordre l'est aussi. Là encore, les gains en performances surcompensent totalement la consommation d'énergie de celle-ci. Par contre, elle est un peu simplifiée. Il y a souvent moins de registres physiques, la ''free list'' est plus petite, les tables de renommage sont plus petites. L'unité de renommage peut donc se retrouver dans des situations où elle manque de registres pour faire le renommage, par exemple. Mais la situation est cependant assez rare.
L'unité de renommage implémente systématiquement les optimisations d'élimination des MOV, les remplacements d'idiomes, les optimisations des calculs triviaux et autres. Leur cout en transistors est très faible, mais le gain en performance est appréciable. Cependant, la vraie raison est que ces optimisations évitent de faire des calculs dans l'ALU ou de bouger des données entre registres. Et ces mouvements/calculs consomment de l'énergie, bien plus que faire le travail dans l'unité de renommage.
===Le décodage est simplifié===
Au niveau du décodage, les processeurs basse consommation utilisent au maximum la '''macro-fusion''' et la '''micro-fusion'''. De telles optimisations permettent de mieux utiliser des petits ROB et des petits ''schedulers''. Il s'agit d'un compromis : on dépense du transistor pour la micro- et macro-fusion, mais on en économise encore plus en réduisant la taille du ROB et des ''schedulers''. Le ROB et les ''schedulers'' sont en effet des sortes de mémoires associatives améliorées et ces dernières consomment beaucoup d'énergie, alors que la micro- et macro-fusion utilisent des circuits combinatoires plus simples.
Les processeurs basse consommation tendent à utiliser le '''prédécodage des instructions''', au lieu d'utiliser un cache de µops. Les processeurs ''in order'' d'ARM utilisent cette technique, de même que les CPU Intel. La raison est qu'un cache de µops demande un budget en transistors assez important. Et qui dit budget en transistors conséquent dit aussi consommation d'énergie en plus. Un cache est en effet une source de consommation énergétique assez importante, et ce même s'il n'est pas utilisé (du fait de courants de fuite). Et les optimisations de consommation permises par le cache de µops ne compensent pas forcément ce cout. Le prédécodage n'a pas autant ce problème, vu que le cache d'instruction est déjà là (même si ajouter les méta-données n'est pas gratuit).
Par contre, si les ''designs'' basse consommation n'ont pas de cache de µops, il n'en est pas de même pour le ''Loop Stream Detector''. Les processeurs basse consommation ont tous une file de µops et intégrer un ''Loop Stream Detector'' n'a pas un cout en transistors conséquent.
===La prédiction de branchements===
Niveau '''''prédiction de branchements''''', ils tendent à ne pas faire trop d'économie. La simple présence d'un pipeline rend la prédiction de branchement nécessaire, et l'exécution dans le désordre la rend encore plus importante. De fait, la prédiction de branchement évite du 'gaspillage", ce qui fait qu'elle améliore aussi bien les performances que la consommation d'énergie, si elle est correctement calibrée. Annuler des instructions exécutées à tord, c'est avant tout gaspiller de l'électricité pour rien.
Le prédécodage permet aussi de faire des économies au niveau de la prédiction de branchements. Il simplifie les décodeurs, ce qui raccourcit le pipeline du processeur, réduisant ainsi la pénalité de prédiction de branchements. De plus, il est possible de se passer de BTB, en placçant les méta-données de prédiction dans le cache L1 d'instruction. Nous avions vu cette possibilité dans le chapitre sur les optimisations du chargement, aussi je ne vais pas revenir dessus. Mais le fait est que cela permet de faire une double économie : pas de cache de µops, pas de BTB.
===Les optimisations du chemin de données===
Les optimisations des processeurs larges sont aussi utilisées. Par exemple, le réseau de contournement est simplifié avec l'usage d'agrégats, le banc de registre a moins de ports ou est dupliqué, etc.
Un autre détail est que les microarchitecture basse consommation ne mettent pas le paquet sur les instructions SIMD. Elles se contentent d'utiliser des unités de calcul 128 bits. Pour l'AVX, qui utilise des registres de 256 bits, ,voire 512, les calculs sont fait en deux fois. La conséquence est qu'une instruction SIMD de type SSE ou AVX, est décodée en deux micro-opérations 128 bits.
==Les processeurs Atom d'Intel, de microarchitecture Bonnell==
L'architecture de l'Atom première génération est assez simple. Son pipeline faisait 16 étages, ce qui est beaucoup. C'est un processeur 32 bits, ce qui aura son importance dans ce qui suit. Il était conçu pour être un processeur basse consommation, donc peu puissant. En conséquence, il n'a pas d'exécution dans le désordre, même s'il est superscalaire. C'était la norme à l'époque pour les processeurs basse consommation, que de faire sans exécution dans le désordre. De nos jours, les choses ont bien changée, même les processeurs basse consommation ont exécution dans le désordre, superscalarité et renommage de registres.
===Le ''front-end'' de l'Atom===
Le cache d'instruction permet de lire 8 octets par cycle, qui sont placés dans une file d'instruction, elle-même suivie par deux décodeurs. Le fait que les décodeurs lisent les instructions depuis une file d'instruction fait que les deux instructions décodées ne sont pas forcément consécutives en mémoire RAM. Par exemple, l'Atom peut décoder un branchement prédit comme pris, suivi par l'instruction de destination du branchement. Les deux instructions ont été chargées dans la file d'instruction et sont consécutifs dedans, alors qu'elles ne sont pas consécutives en mémoire RAM.
Sur l'Atom, la majorité des instructions x86 sont décodées en une seule micro-opération, y compris les instructions ''load-up''. Le microcode n'est utilisé que pour une extrême minorité d'instructions et est à part des deux décodeurs précédents. L'avantage est que cela permet d'utiliser au mieux la file de micro-opération, qui est de petite taille. Mais surtout, cela permet de grandement réduire la consommation du processeur, au détriment de ses performances.
Pour avoir un décodage rapide, malgré des instructions complexes, le processeur recourt à la technique du pré-décodage, qui prédécode les instructions lors de leur chargement dans le cache d'instruction. Le prédécodage lui-même prend deux cycles, là où une lecture dans le L1 d'instruction en prend 3. les défauts de cache d'instruction sont donc plus longs de deux cycles. Mais l'avantage du prédécodage est que la consommation d'énergie est diminuée. Prenez une instruction exécutée plusieurs fois, dans une boucle. Au lieu de décoder intégralement une instruction à chaque fois qu'on l'exécute, on la prédécode une fois, seul le reste du décodage est fait à chaque exécution. D'où un gain d'énergie assez intéressant. Les caches de micro-opération, qui sont capables d'exécuter une optimisation similaire, n'existaient pas encore à cette époque.
===Le chemin de données de l'Atom===
Les deux décodeurs alimentent une file de micro-opérations de petite taille : 32 µops maximum, 16 par ''thread'' si le ''multithreading'' matériel est activé. La file de micro-opérations a deux ports d'émission, ce qui permet d'émettre au maximum 2 µops par cycle. Les conditions pour cela sont cependant drastiques. Les deux instructions ne doivent pas avoir de dépendances de registres, à quelques exceptions près liées au registre d'état. Le multithreading matériel doit aussi être désactivé. Les deux instructions doivent aller chacun dans un port différent, et cela tient en compte du fait que les deux ports sont reliés à des unités de calcul fort différentes. Le tout est illustré ci-dessous.
Les deux ports ont chacun une ALU simple dédiée, capable de faire des additions/soustractions, des opérations bit à bit et des copies entre registres. Mais ils ont aussi des opérations qui leur sont spécifiques. La séparation entre les deux pipelines est assez complexe. Il ne s'agit pas du cas simple avec un pipeline entier et un pipeline flottant séparés. En réalité, il y a deux pipelines, chacun capables de faire des opérations entières et flottantes, mais pas les mêmes opérations.
Le premier port permet d’exécuter des opérations entières simples, une addition flottante, des comparaisons/branchements, ou une instruction de calcul d'adresse LEA. Le second port/pipeline est, quant à lui, conçu pour exécuter les instruction ''load-up'' nativement, en une seule micro-opération. Il contient toute la machinerie pour faire les accès mémoire, notamment des unités de calcul d'adresse et un cache L1 de données. A la suite du cache, se trouvent une ALU entière simple, un ''barrel shifter'', et un circuit multiplieur/diviseur. Le circuit multiplieur/diviseur est utilisé à la fois pour les opérations flottantes et entières.
[[File:Intel Atom Microarchitecture.png|centre|vignette|upright=2.5|Intel Atom Microarchitecture]]
Cette organisation difficile à comprendre est en réalité très efficace, très économe en circuit, tout en gardant une performance intéressante. Les instructions simples, ADD/SUB/bitwise sont supportées dans les deux pipelines. Il faut dire que ce sont des opérations courantes qu'il vaut mieux optimiser au mieux. Le processeur peut donc émettre deux opérations simples et fréquentes en même temps, ce qui augmente les performances.
Les opérations plus complexes, à savoir les multiplications/divisions/décalages/rotations/manipulations de bit sont supportées dans un seul pipeline. La raison est qu'il est rare que de telles opérations soient consécutives, et qu'il n'est donc pas utile d'optimiser pour cette situation. Si les deux pipelines devaient supporter ces opérations, cela demanderait de dupliquer les circuits multiplieurs/diviseur, ce qui aurait un cout en circuit important pour un gain en performance assez faible.
===Le système d'exceptions flottantes de l'Atom===
Le processeur étant sans exécution dans le désordre, ses instructions doivent écrire dans les registres dans l'ordre du programme. En conséquence, certaines instructions doivent être retardées, leur émission doit attendre que les conditions soient adéquates. Et cela pose problème avec les opérations flottantes, vu qu'elles prennent pas mal de cycles pour s'exécuter. Imaginez qu'une instruction flottante de 10 cycles soit suivie par une instruction entière. En théorie, on doit retarder l'émission de l'instruction entière de 9 cycles pour éviter tout problèmes. Le cout en performance est donc assez important.
En théorie, les instructions entières et flottantes écrivant dans des registres séparés, ce qui fait que l'on pourrait exécuter instructions entières et flottantes dans le désordre. Sauf pour les instructions de copie entre registres entier et flottants, mais laissons-les de côté. Le problème est qu'une instruction flottante peut parfois lever une exception, par exemple en cas de division par zéro, ou pour certains calculs précis. Si une exception est levée, alors l'instruction flottante est annulée, de même que toutes les instructions qui suivent, y compris les opérations entières.
Ce n'est pas un problème si le processeur gère nativement les exceptions précises, par exemple avec un tampon de ré-ordonnancement. Mais l'Atom étant un processeur sans exécution dans le désordre, les instructions entières devraient être mises en attente tant qu'une instruction flottante est en cours d'exécution. Heureusement, l'Atom d'Intel a trouvé une parade. La technique, appelée ''Safe Instruction Recognition'' par Intel, est décrite dans le brevet US00525721.6A.
L'idée est de tester les opérandes flottantes, pour détecter les combinaisons d'opérandes à problème, dont l'addition/multiplication peut lever une exception. Si des opérandes à problème sont détectées, on stoppe l'émission de nouvelles instructions en parallèle de l'instruction flottante et l'unité d'émission émet des bulles de pipeline tant que l'instruction flottante est en cours. Sinon, l'émission multiple fonctionne. La technique permet ainsi de ne pas écrire dans les registres entiers/flottants dans l'ordre du programme : une instruction entière peut être autorisée à s'exécuter même si elle écrit dans un registre entier avant qu'une instruction flottante délivre son résultat.
==Les microarchitectures de Silvermont à Crestmont==
Les microarchitectures qui font suite à l'architecture Bonnell sont, dans l'ordre chronologique : Silvermont, Goldmont, Tremont, Gracemont et Crestmont. Elles utilisent toute l'exécution dans le désordre, avec renommage de registres. Elles sont aussi superscalaires, avec une progression chronologique de la triple vers la pentuple émission, voire au-delà. Les différentes microarchitecture sont toutes superscalaires, avec une progression chronologique de la triple vers la pentuple émission, voire au-delà. Il faut noter que la microarchitecture Tremont pouvait décoder six instructions par cycles, mais émettre seulement 4 µops par cycle. Idem avec Gracemont , qui était limitée à 5 µops par cyle, là encore à cause de l’unité de renommage.
{|class="wikitable"
|-
! Processeur !! Chargement !! Décodage !! Renommage
|-
! Silvermont
| 16 octets par cycle || 2 instructions par cycle || 2 µops par cycle
|-
! Goldmont
| 16 octets par cycle || 3 instructions/cycle || 2 µops par cycle
|-
! Tremont
| 32 octets par cycle || 6 instructions/cycle || 4 µops par cycle
|-
! Gracemont
| 32 octets par cycle || 6 instructions/cycle || 5 µops par cycle
|-
! Crestmont
| 6 octets par cycle || 6 instructions par cycle || 6 µops par cycle
|}
Les microarchitectures Tremont, Gracemont et Crestmont sont des processeurs superscalaires assez larges, avec exécution dans le désordre, ce qui est assez étonnant pour de la basse consommation, mais qui est devenu la norme depuis longtemps, grâce à la loi de Moore. Néanmoins, cela vient avec des optimisations pour réduire le cout en circuit/énergie. Et ces optimisations sont intéressantes à étudier. Par contre, certaines implémentations de l’exécution dans le désordre sont plus consommatrices que d'autres. Et cela permet de faire le lien avec le chapitre précédent.
Dans le chapitre précédent, j'ai dit que les processeurs Intel utilisaient une station de réservation centralisée, alors qu'AMD préférait la décentraliser à l'excès. AMD pousse le bouchon jusqu'à avoir une station de réservation par unité, que ce soit les ALU, les FPU, les unités mémoire, et même les unités de branchement. Il se trouve que la solution d'AMD est nettement meilleure.
L'utilisation de '''stations de réservations décentralisées''' est une première optimisation. Et quand je dis décentralisé, c'est au point où chaque ALU entière a sa propre station de réservation. Cela réduit les performances par rapport à une station de réservation centralisée, ou un moins un système moins décentralisé, mais entraine un gain en consommation d'électricité non négligeable.
Une seconde optimisation est que les stations de réservation pour les opérations flottantes sont séparées des autres. Il n'y a pas, comme sur les microarchitectures Intel, de ports d'émission qui partagent ue ALU et une FPU, voire une ALU et une unité SIMD. La raison est que si le programme exécuté n'utilise pas d'instructions SIMD, les circuits associés peuvent être ''clock gatés'', voire éteints. Les stations de réservation SIMD ou Flottante, les registres SIMD/flottants, les FPU, les unités SIMD : tout cela est simplement mis en veille. Le gain en termes de consommation est alors assez important, surtout avec les instructions SIMD.
Une troisième optimisation est liée aux FPU, et précisément aux stations de réservation associées. Ou plutôt devrais-je dire : la station de réservation flottante. Car si les ALU entières ont chacune leur propre station de réservation, les FPU et les unités SIMD tendent à avoir une seule station de réservation, partagée entre toutes les FPUs. La raison à cela est que cela convient mieux aux opérations flottantes. Elles ont une durée plus longue, attendent plus longtemps dans les stations de réservation, etc. bref, des détails techniques qui font que c'est mieux ainsi.
Une dernière optimisation, liée à la précédente, est l'usage de '''files d'instructions pour les opérations mémoire'''. Si ces processeurs utilisent des stations de réservation pour les opérations entières, ce n'est pas le cas pour les opérations mémoire. Les accès mémoire sont donc exécutés dans l'ordre, contrairement aux autres opérations. Il arrive aussi que ce soit la même chose, mais pour les opérations flottantes, encore que ce soit plus rare. . Les performances mémoire sont donc un peu réduites, mais le gain en termes d'énergie en vaut la peine.
Une particularité de ces microarchitectures est l'absence du cache de micro-opérations. Pourtant, un tel cache devrait être une source d'économies d'énergie. Lire des µops dans ce cache évite d'avoir à accéder au cache d’instruction, décoder des instructions, etc. Sans doute que le cout en circuit est plus faible, et que ce gain en circuit est considéré comme plus important que les économies d'énergie et les gains en performances qui vont avec.
===Les unités fonctionnelles et l'exécution dans le désordre===
Pour ce qui est de l'exécution dans le désordre, il faut préciser que Silvermont et Goldmont n'utilisent pas le même mécanisme de renommage de registre. Silvermont fait le renommage dans le ROB, avec un banc de registre architectural et un banc de registres renommés. L'avantage est que cette implémentation est économe en circuits, même si elle entraine des déplacements de données et des copies entre registres réguliers. Elle permet notamment de réduire le nombre de ports du banc de registre, en répartissant ceux-ci sur le banc de registre architectural et renommés. Goldmont passe à un renommage avec un banc de registre physique unique.
Silvermont a deux ALU entières, un ''barrel shifter'', un multiplieur entier, une unité mémoire unique, un additionneur flottant et un multiplier flottant. Le tout est repartit sur 5 pseudo-ports d'émission, en réalité 5 stations de réservation de 8 entrées, chacune connectée à quelques unités de calcul. Le tout est organisé comme suit
{|class="wikitable"
|+ Silvermont
|-
! Station de réservation 1 !! Station de réservation 2 !! Station de réservation 3 !! Station de réservation 4 !! Station de réservation 5
|-
| 8 entrées || 8 entrées || 6 entrées || 8 entrées || 8 entrées
|-
| colspan="5" |
|-
| ALU entière || ALU entière || LOAD/STORE || FADD || FMUL
|-
| ''Barrel shfiter'' || Multiplieur entier || || ALU SIMD entière (128bits) || ALU SIMD entière (128bits)
|-
| || Branchements || ||
|}
Goldmont fait les modifications suivantes : il rajoute une ALU entière, les stations de réservations augmentent en taille, il fusionne les stations de réservation pour les unités flottantes.
De plus, l'ALU pour les branchements reçoit sa propre réservation de station. L'avantage est que cela permet d'exécuter les branchements en priorité, et dont de savoir s'ils sont pris ou pas en avance, et à quelle adresse brancher cas échant. Le plus tôt ces informations sont connues, au mieux la prédiction de branchement fonctionne bien, au mieux on peut annuler les instructions chargées à tord.
{|class="wikitable"
|-
|+ Goldmont
|-
! Station de réservation 1 !! Station de réservation 2 !! Station de réservation 3 !! Station de réservation 4 !! Station de réservation 5 !! Station de réservation 6 !! Station de réservation 7 !! Station de réservation 8
|-
| 10 entrées || 10 entrées || 10 entrées || 8 entrées || colspan="2" | 13 entrées || colspan="2" | 27 entrées
|-
| colspan="8" |
|-
| ALU entière || ALU entière || ALU entière || Branchements || LOAD || STORE || FADD || FMUL
|-
| ''Barrel shfiter'' || Multiplieur entier || || || || || ALU SIMD entière (128bits) || ALU SIMD entière + MUL SIMD entière (128bits)
|}
Les microarchitectures suivantes ont plus d'unités de calcul.
{|class="wikitable"
|-
|+ Tremont
|-
! Station de réservation 1 !! Station de réservation 2 !! Station de réservation 3 !! Station de réservation 4 !! Station de réservation 5 !! Station de réservation 6 !! Station de réservation 7 !! Station de réservation 8 !! Station de réservation 9 !! Station de réservation 10
|-
| 12 entrées || 12 entrées || 7 entrées || 17 entrées || colspan="2" | 13 entrées || 14 entrées || 13 entrées || colspan="2" | 24 entrées
|-
| colspan="8" |
|-
| ALU entière || ALU entière || ALU entière || Branchements || LOAD || LOAD || STORE || FSTORE (écriture) || ALU SIMD (128 bits) || ALU SIMD (128 bits)
|-
| ''Barrel shfiter'' || Multiplieur entier || || || || || SIMD FMUL (128bits) || SIMD FADD (128bits)
|-
| || || || || || || FDIV ||
|}
{|class="wikitable"
|-
|+ Gracemont
|-
! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS !! RS
|-
| 15 entrées || 16 entrées || 16 entrées || 15 entrées || colspan="2" | 42 entrées
| colspan="4" | 22 entrées
| 13 entrées || colspan="2" | 24 entrées
|-
| ALU || ALU || ALU || ALU || Branch || Branch
| LOAD || LOAD || STORE || STORE
| FSTORE (écriture) || FSTORE (écriture) || ALU SIMD (128 bits) || ALU SIMD (128 bits) || ALU SIMD (128 bits)
|-
| || MUL entier || MUL entier || || ||
| || || ||
| SIMD FMA (128bits) || SIMD FMA (128bits)
|-
| || DIV entier || DIV entier || || ||
| || || ||
| SIMD FADD (128bits) || SIMD FADD (128bits)
|-
| || LEA || Manipulation de bits || || ||
| || || ||
| SIMD FMUL (128bits) || SIMD FMUL (128bits)
|}
===L'unité mémoire et la FPU des CPU Intel basse consommation===
Silvermont et Goldmont se limitent à une simple unité mémoire, qui a cependant deux ports séparés pour Goldmont. L'économie en circuit est conséquente, car ils se débrouillent avec une ''load queue'' et une ''Store queue'', toutes deux simple port (si on omet le port pour le ''store to load forwarding''). De même, le cache L1 peut se débrouiller avec seulement une implémentation naturelle, à savoir un port de lecture et un d'écriture (le cache est naturellement double port, vu que les entrées de lecture et d'écriture sont séparées sur les cellules de SRAM). Pas besoin d'utiliser de structures matérielle avec un port de plus que de nature, ce qui est un énorme avantage en termes d'économie de circuits et de consommation énergétique.
C'est sur les microarchitecture suivantes que démarrent les hostilités. Tremont ajoute un second port pour émettre une lecture, Gracemont ajoute un second port pour les écritures en plus (4 ports au total). Crestmont uant à elle, dispose de trois ports d'émission pour les lectures et de deux ports d'émission pour les écritures.
De plus, la file de µops mémoire est scindée en deux : une file de µops mémoire, suivie par une station de réservation de petite taille. Les µops mémoire sont accumulées dans la file de µop quand les stations de réservation sont pleines, histoire de ne pas bloquer l'émission en cas de défaut de cache un peu trop prolongé. Prenons une µop mémoire qui est tout juste ''dispatch'' : l'unité de ''dispatch'' regarde si il y a de la place dans la station de réservation. Si c'est le cas, elle envoie la µop mémoire à la station de réservation. Sinon, elle l'envoie dans la file de µops mémoire.
Tremont utilise une file de µops de 21 entrées, alors que la station de réservation en fait 13, ce qui fait un total de 34 entrées. Les performances sont moindres comparé à une station de réservation de 34 entrées, car les µops dans la file de µops mémoire ne peuvent pas être émises tant qu'elles n'ont pas atteint les stations de réservations, même si les conditions sont remplies en matière d'opérandes pour qu'elles puissent passer devant. Mais le gain en circuits est conséquent, de même que l'économie d'énergie, pour une perte de performance plus que tolérable.
Les opérations flottantes étant assez longues, Gracemont utilise le même système pour les opérations flottantes. Une file de µops précède deux stations de réservation. La première accumule des opérations flottantes, destinées à être exécutées dans la FPU (de type SIMD). La file de µops fait 56 entrées, alors que la station de réservation en a 35. Une seconde station de réservation de 18 entrées met en attente les écritures flottantes, à savoir les écritures qui écrivent un flottant simple ou double précision en mémoire RAM.
Le schéma ci-dessous illustre ce qu'il en est sur l'architecture Crestmont. On voit que les files pour µops flottantes et mémoire sont de type ''non-scheduling'', ce qui veut dire que ce sont de simples files d'instruction.
[[File:Crestmont.png|centre|vignette|upright=3|Crestmont]]
===Le ''front-end'' : un changement après Silvermont et Goldmont===
Le ''front-end'' commence avec le cache d'instruction et la file d'instruction. Le cache d'instruction est un cache simple port, capable de lire 16 octets consécutifs par cycle. Ces 16 octets peuvent contenir un nombre variable d'instructions, de 16 instructions maximum à une seule (une instruction x86 fait entre 1 et 16 octets). Sur l'architecture Silvermont, la file d'instruction peut mémoriser maximum 6 blocs de 16 octets, elle a donc une capacité de 6 blocs.
La file d'instruction est suivie par plusieurs décodeurs simples et un microcode. L'architecture Silvermont n'a que deux décodeurs simples, alors que Goldmont en a 3. Le décodage d'une instruction prend trois cycles d'horloge, le décodage est évidemment pipeliné. Il faut noter que les instructions ''load-op'' sont décodées en une seule µop, ils utilisent la technique de la micro-fusion. Ils alimentent une file de µops, capable de mémoriser 32 µops sur Silvermont.
{|class="wikitable"
|-
! !! Silvermont !! Goldmont
|-
! Chargement
| colspan="2" | 16 octets par cycle
|-
! File d'instruction
| 6 blocs, donc 6* 16 octets ||
|-
! Decodage
| Deux instructions par cycle || Trois instructions par cycle
|-
! File de µops
| 32 µops ||
|}
En remplacement du cache de µops, ces microarchitectures, depuis Tremont, utilisent un ''front-end'' tel que décrit dans le chapitre sur les processeurs superscalaires, dans la section "''Les optimisations des processeurs superscalaires larges''". Faisons un rapide rappel, avec une remise en contexte "historique".
Goldmont était une microarchitecture triple émission, avec trois décodeurs simples (et un microcode). La microarchitecture suivante, Tremont, souhaitait augmenter la largeur du processeur. Idéalement, il aurait fallu recréer le ''front-end'' pour pouvoir charger 4 à 5 instructions simultanément, et surtout les décoder. Mais ce n'est pas ce qui a été fait. A la place, elles ont décidées de dupliquer les décodeurs, et de les coller ensemble en utilisant l'unité de prédiction de branchement.
Il y a donc 6 décodeurs, qui sont organisés en deux paquets de trois, en deux ''clusters''. Et cette organisation en ''cluster'' est primordiale pour comprendre la suite. Le processeur ne charge pas 6 instructions consécutives, mais 2 blocs de 3 instructions. Les deux blocs ne sont donc pas consécutifs en mémoire RAM, bien au contraire : ils sont séparés par des branchements. Le premier bloc est chargé normalement, mais le second bloc est décidé par la prédiction de branchement. La conséquence est qu'en absence de branchements, le processeur ne peut que charger/décoder 3 instructions consécutives, pas 6. Pour charger deux blocs non-consécutifs, le cache d'instruction est un cache double port.
Notons que ces ''clusters'' ont leur propre file d'instruction, et leur propre file de µops ! Il y a donc deux files de µops en sortie des deux ''clusters'' de décodeurs. L'unité de renommage choisit à chaque cycle quelle file de µops utiliser, suivant leur remplissage ou d'autres informations. Sur Tremont, l'unité de renommage peut renommer 4 µops, même si le processeur peut décoder 6 instructions en parallèle dans le meilleur des cas. Mais ce n'est pas un problème énorme, car le processeur n'atteint pas toujours les 6 µops par cycle. Il oscille entre 3 et 6 µops par cycle, parfois moins si les conditions ne sont pas réunies. Les files de µops découplent décodeurs et unités de renommages, histoire de fluidifier le trafic.
[[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]]
La microarchitecture '''Gracemont''' peut décoder deux paquets de 3 instructions chaque. Elle envoie ensuite 5 µops à l'unité d'émission, qui sont distribuées dans deux pipelines : un pipeline flottant/SIMD et un pipeline entier. Et le pipeline entier peut émettre 4 µops entières, deux lectures, deux écritures et deux branchements ! Pire que ça, le ''barrel shifter'' et le multiplieur/diviseur sont dupliqués en deux exemplaires !
[[File:Gracemont.png|centre|vignette|upright=3|Gracemont]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les microarchitectures pour le x86 : haute performance
| prevText=Les microarchitectures pour le x86 : haute performance
| next=Les architectures parallèles
| nextText=Les architectures parallèles
}}
</noinclude>
dqtl83ogizx74shmgzwtdr5ep15l30o
Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations
0
84538
774762
774204
2026-10-11T01:20:57Z
Mewtow
31375
/* Le découplage décodage-renommage */
774762
wikitext
text/x-wiki
Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique.
Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque.
Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance.
Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs.
==La spécialisation des ''schedulers'' décentralisés==
La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente.
===Les ''schedulers'' spécialisés pour les µops flottantes===
Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''.
[[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=2|Processeur avec des fenêtres d'instruction décentralisées]]
L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins.
Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard.
L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops.
[[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]]
[[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]]
Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées.
Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre.
===Les ''schedulers'' spécialisés pour les calculs d'adresse===
Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières.
Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre.
Le transfert de l'adresse calculée peut se faire de plusieurs manières, mais nous en parlerons en détail dans le chapitre sur la désambiguïsation mémoire. Pour le moment, on suppose qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse.
L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible.
===Les stations de réservation===
Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''.
Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86.
Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés.
[[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]]
Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié.
==Les ''schedulers'' décentralisés versus centralisés==
Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur.
===Un ''scheduler'' centralisé est plus flexible, mais plus couteux===
Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature.
Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé.
Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats.
===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''===
Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''.
Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes.
Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus.
Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1.
==Les files de µops et l'exécution dans le désordre==
Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible
===L'exécution dans le désordre avec des files de µops===
Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel.
Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps.
Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle.
L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante.
Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète.
L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète.
Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération.
{|class="wikitable"
|-
!
! File de µops normale
! ''scheduler''
|-
! un seule
| ''Scoreboard''
| ''scheduler'' centralisée
|-
! Plusieurs
| Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants
| ''scheduler'' décentralisé
|}
===L'usage de files d'instruction en complément des ''schedulers''===
S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures.
Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis.
Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière.
La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant.
===Le découplage décodage-renommage===
Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste.
Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage.
Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme.
[[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]]
Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable.
Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le renommage de registres
| prevText=Le renommage de registres
| next=Le scoreboarding et l'algorithme de Tomasulo
| nextText=Le scoreboarding et l'algorithme de Tomasulo
}}
</noinclude>
8cria1mamxq6a41epesq4lznwvdrwdq
774786
774762
2026-10-11T01:45:09Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
774786
wikitext
text/x-wiki
Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique.
Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque.
Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance.
Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs.
==La spécialisation des ''schedulers'' décentralisés==
La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente.
===Les ''schedulers'' spécialisés pour les µops flottantes===
Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''.
[[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=2|Processeur avec des fenêtres d'instruction décentralisées]]
L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins.
Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard.
L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops.
[[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]]
[[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]]
Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées.
Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre.
===Les ''schedulers'' spécialisés pour les calculs d'adresse===
Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières.
Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre.
Le transfert de l'adresse calculée peut se faire de plusieurs manières, mais nous en parlerons en détail dans le chapitre sur la désambiguïsation mémoire. Pour le moment, on suppose qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse.
L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible.
===Les stations de réservation===
Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''.
Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86.
Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés.
[[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]]
Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié.
==Les ''schedulers'' décentralisés versus centralisés==
Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur.
===Un ''scheduler'' centralisé est plus flexible, mais plus couteux===
Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature.
Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé.
Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats.
===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''===
Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''.
Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes.
Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus.
Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1.
==Les files de µops et l'exécution dans le désordre==
Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible
===L'exécution dans le désordre avec des files de µops===
Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel.
Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps.
Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle.
L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante.
Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète.
L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète.
Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération.
{|class="wikitable"
|-
!
! File de µops normale
! ''scheduler''
|-
! un seule
| ''Scoreboard''
| ''scheduler'' centralisée
|-
! Plusieurs
| Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants
| ''scheduler'' décentralisé
|}
===L'usage de files d'instruction en complément des ''schedulers''===
S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures.
Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis.
Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière.
La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant.
===Le découplage décodage-renommage===
Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste.
Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage.
Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme.
[[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]]
Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable.
Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le renommage de registres
| prevText=Le renommage de registres
| next=Le scoreboarding et l'algorithme de Tomasulo
| nextText=Le scoreboarding et l'algorithme de Tomasulo
}}
</noinclude>
cu2veka7w8d1z0v024za3ymqiig5mvf
Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order
0
84543
774773
774219
2026-10-11T01:28:06Z
Mewtow
31375
/* La micro-fusion des processeurs Intel et AMD */
774773
wikitext
text/x-wiki
La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur.
Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter.
La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''.
[[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]]
==Les ''schedulers'' des processeurs superscalaires==
Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine.
La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance.
Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction.
Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme.
===L'exemple du PA-RISC PA 8000===
Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit.
Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique.
Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux.
L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment.
Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées.
Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique.
À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type.
: S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission !
À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste.
le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine.
[[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]]
===La superscalarité avec un ''scheduler'' centralisé===
Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre.
La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre.
Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage.
La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops.
Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle.
===La superscalarité à ''schedulers'' décentralisés===
Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''.
Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission.
: Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse.
Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU.
[[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]]
Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops.
Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''.
[[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]]
Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU.
[[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]]
===La superscalarité à ''schedulers'' totalement décentralisés===
Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission).
Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ).
[[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]]
Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées.
La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles.
[[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]]
Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique.
Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation.
==L'unité de renommage superscalaire==
Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées.
Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat.
[[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]]
Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante.
[[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]]
Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande.
[[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]]
==L'unité mémoire/LSQ des processeurs superscalaires==
Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''.
Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses.
===L'excès d'AGU avec une LSQ simple===
Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache".
Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus.
===L'ajout de ports sur le cache===
[[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]]
Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport.
L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière.
Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes.
[[File:Emissim multiple des µops mémoire.png|vignette|upright=1|Emission multiple des µops mémoire avec des AGUs spécialisées.]]
S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE.
Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture).
===AGUs banalisées versus spécialisées===
Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU.
L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent.
L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une LSQ LOAD et une LSQ STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la LSQ LOAD et à la LSQ STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la LSQ LOAD et l'AGU pour les écritures à la LSQ STORE.
[[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]]
Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache).
==Le ''front-end'' des processeurs superscalaires larges==
Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Voyons ce qu'il en est.
===L'étape de chargement d'un CPU superscalaire large===
Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble.
Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part.
La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime.
La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements.
Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées.
[[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]]
Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles.
[[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]]
Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements !
[[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]]
Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''.
===Le décodage superscalaire large===
Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle.
Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total).
Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''.
Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle.
[[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]]
Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''.
Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''.
==Le ''back-end'' des processeurs superscalaires larges==
Les processeurs superscalaires larges incorporent un grand nombre d'unités de calcul. Que ce soit pour des ALU entières, des AGUs ou des FPUs. Un processeur superscalaire actuel, de la moitié des années 2020, contient entre une dizaine et une vingtaine d'unités de calcul, AGU inclues. Par exemple, les processeurs Zen 2, sortis en 2019, avaient exactement 4 ports d'émission pour les ALU entières, 3 pour les AGUs, deux multiplieurs flottants et deux additionneurs flottants. La microarchitecture Gracemont d'Intel, datant approximativement des mêmes années, a 4 ports d'émission pour les ALUs, 2 pour les branchements, 4 pour les AGUs, 3 pour les FPUs. Et ces ports sont de plus partagés.
Un grand nombre d'unités de calcul entraine de nombreuses complications. Les unités de calcul doivent être alimentées, ce qui demande d'ajouter des ports de lecture/écriture sur le banc de registres. De plus, le réseau de contournement doit interconnecter toutes les unités de calcul entre elles. Faites les comptes : avec 3 AGUs et 4 ports d'ALUs (partagés), cela fait 14 ports de lecture sur le banc de registres, et 7*7 possibilités d'interconnexions. C'est trop, beaucoup trop. Aussi, diverses optimisations visent à réduire ces couts, pour rendre un grand nombre d'ALU praticable.
===Le contournement sur les processeurs superscalaires larges===
Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup.
Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant.
La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée.
===Le banc de registre des processeurs superscalaires larges===
Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions.
La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter'').
Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres.
Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''.
[[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]]
Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat.
==La macro-fusion : une optimisation du décodage parallèle==
Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes.
La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP.
Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''.
Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis.
La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs...
==Le ''stack engine'' : une optimisation de la pile d'appel==
Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière.
L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié.
===Le ''stack engine''===
L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle.
Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre.
L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire.
Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''.
Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits.
===Les points de synchronisation du delta===
La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué.
D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte.
Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème.
La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération.
==La micro-fusion et la délamination==
La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications.
L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage.
L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre.
Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés.
===Les domaines de fusion===
La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc.
J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc.
Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté.
Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation.
Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU.
Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type.
===La délamination===
La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement.
Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations.
===La micro-fusion des processeurs Intel et AMD===
La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''.
Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''.
Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout.
La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les processeurs superscalaires in-order
| prevText=Les processeurs superscalairess in-order
| next=Les processeurs VLIW et EPIC
| nextText=Les processeurs VLIW et EPIC
}}
</noinclude>
cbpe1a8zvylvplfys4vask3qzezbhu0
Mathc initiation/007n
0
84562
774613
774576
2026-10-10T12:47:25Z
Xhungab
23827
774613
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
{{Partie{{{type|}}}|Se familiariser avec les series de Fourier avec f : -> x^2}}
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/007h|f2.h ]] ...... '''f(t) = t^2 avec t = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007i|c2a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007j|c2an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007k|c2bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot[-pi:pi][-1.:10] !! plot[-pi:4*pi][-1.:10]
|-
| [[File:Fourier serie for my wikibook 2.png|thumb|]] ||
[[File:Fourier serie for my wikibook 3.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007o|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007l|c2fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007m|c2fsN.c]]
==Les spectres d'amplitudes et les spectres de phases==
* [[Mathc initiation/007q|c2fsap.c]]
{{AutoCat}}
m0u06x293zhxgpcx1tfetyb249v6yc6
774746
774613
2026-10-10T21:22:38Z
Xhungab
23827
774746
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594| Sommaire]]
:
:
==La fonction : f(x) = x^2 .................. avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/007h|f2.h ]] ...... '''f(t) = t^2 avec t = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007i|c2a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007j|c2an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007k|c2bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot[-pi:pi][-1.:10] !! plot[-pi:4*pi][-1.:10]
|-
| [[File:Fourier serie for my wikibook 2.png|thumb|]] ||
[[File:Fourier serie for my wikibook 3.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007o|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007l|c2fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007m|c2fsN.c]]
==Les spectres d'amplitudes et les spectres de phases==
* [[Mathc initiation/007q|c2fsap.c]]
{{AutoCat}}
2rzunzeldbwph7yhthp97r7vl4p1qap
774749
774746
2026-10-10T22:02:05Z
Xhungab
23827
774749
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x^2 .................. avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/007h|f2.h ]] ...... '''f(t) = t^2 avec t = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007i|c2a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007j|c2an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007k|c2bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot[-pi:pi][-1.:10] !! plot[-pi:4*pi][-1.:10]
|-
| [[File:Fourier serie for my wikibook 2.png|thumb|]] ||
[[File:Fourier serie for my wikibook 3.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007o|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007l|c2fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007m|c2fsN.c]]
==Les spectres d'amplitudes et les spectres de phases==
* [[Mathc initiation/007q|c2fsap.c]]
{{AutoCat}}
7bcrfbto3ik6yxg15bos8i1p8epuj6e
Fonctionnement d'un ordinateur/Les pipelines multicycles
0
84571
774597
2026-10-10T12:24:45Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]]
774597
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]]
ij84gshdmbv2qu5hfybbc82p1ortsbz
Fonctionnement d'un ordinateur/Les pipelines in-order
0
84572
774602
2026-10-10T12:33:12Z
Mewtow
31375
Page créée avec « Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions. Les pipelines dyna... »
774602
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Les deux instructions ont alors une '''dépendance de donnée'''. L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ca sera le sujet de la fin de ce chapitre, ainsi que du suivant.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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.
Mais cela implique que 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. Il a un port de lecture séparé du port d'écriture. Le port de lecture est utilisé pour l'étage MEM, alors que le port d'écriture est utilisé dans l'étage de ''Writeback''.
Mais, il faut aussi propager les écritures d'un étage de pipeline au suivant, jusqu'à atteindre l'étage de ''Writeback''. Pour cela, il est possible d'enchainer plusieurs registres de pipeline. Mais nous avons vu dans le chapitre sur les mémoires FIFO qu'enchainer plusieurs registres à la suite est équivalent à utiliser une mémoire FIFO. Aussi, une solution équivalente une mémoire FIFO qui met en attente les écritures dans le cache. Elle est appelée la ''Write Queue'', '''file d'écriture''' en français.
: 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 est une simple mémoire FIFO, contenant quelques entrées. Une entrée contient une donnée et l'adresse d'écriture. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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, faites à tord.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Store Queue sur un pipeline fixe]]
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>
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
gyywswm5mb2q85qh53ymwf8ouhl1g5a
774604
774602
2026-10-10T12:33:44Z
Mewtow
31375
/* Les utilisations détournées du système d'exception précise */
774604
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Les deux instructions ont alors une '''dépendance de donnée'''. L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ca sera le sujet de la fin de ce chapitre, ainsi que du suivant.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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.
Mais cela implique que 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. Il a un port de lecture séparé du port d'écriture. Le port de lecture est utilisé pour l'étage MEM, alors que le port d'écriture est utilisé dans l'étage de ''Writeback''.
Mais, il faut aussi propager les écritures d'un étage de pipeline au suivant, jusqu'à atteindre l'étage de ''Writeback''. Pour cela, il est possible d'enchainer plusieurs registres de pipeline. Mais nous avons vu dans le chapitre sur les mémoires FIFO qu'enchainer plusieurs registres à la suite est équivalent à utiliser une mémoire FIFO. Aussi, une solution équivalente une mémoire FIFO qui met en attente les écritures dans le cache. Elle est appelée la ''Write Queue'', '''file d'écriture''' en français.
: 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 est une simple mémoire FIFO, contenant quelques entrées. Une entrée contient une donnée et l'adresse d'écriture. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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, faites à tord.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Store Queue sur un pipeline fixe]]
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>
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
72r6az3lh2uzr5f30et4rm066qw399w
774607
774604
2026-10-10T12:34:51Z
Mewtow
31375
774607
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Les deux instructions ont alors une '''dépendance de donnée'''. L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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.
Mais cela implique que 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. Il a un port de lecture séparé du port d'écriture. Le port de lecture est utilisé pour l'étage MEM, alors que le port d'écriture est utilisé dans l'étage de ''Writeback''.
Mais, il faut aussi propager les écritures d'un étage de pipeline au suivant, jusqu'à atteindre l'étage de ''Writeback''. Pour cela, il est possible d'enchainer plusieurs registres de pipeline. Mais nous avons vu dans le chapitre sur les mémoires FIFO qu'enchainer plusieurs registres à la suite est équivalent à utiliser une mémoire FIFO. Aussi, une solution équivalente une mémoire FIFO qui met en attente les écritures dans le cache. Elle est appelée la ''Write Queue'', '''file d'écriture''' en français.
: 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 est une simple mémoire FIFO, contenant quelques entrées. Une entrée contient une donnée et l'adresse d'écriture. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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, faites à tord.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Store Queue sur un pipeline fixe]]
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>
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
iqhktagpieep4okpwm1r53uz1lzfrl0
774608
774607
2026-10-10T12:38:48Z
Mewtow
31375
/* La gestion des dépendances de données */
774608
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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.
Mais cela implique que 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. Il a un port de lecture séparé du port d'écriture. Le port de lecture est utilisé pour l'étage MEM, alors que le port d'écriture est utilisé dans l'étage de ''Writeback''.
Mais, il faut aussi propager les écritures d'un étage de pipeline au suivant, jusqu'à atteindre l'étage de ''Writeback''. Pour cela, il est possible d'enchainer plusieurs registres de pipeline. Mais nous avons vu dans le chapitre sur les mémoires FIFO qu'enchainer plusieurs registres à la suite est équivalent à utiliser une mémoire FIFO. Aussi, une solution équivalente une mémoire FIFO qui met en attente les écritures dans le cache. Elle est appelée la ''Write Queue'', '''file d'écriture''' en français.
: 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 est une simple mémoire FIFO, contenant quelques entrées. Une entrée contient une donnée et l'adresse d'écriture. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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, faites à tord.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Store Queue sur un pipeline fixe]]
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>
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
706mpvel8ka8s4qwa4kjbx5p0e1z8lv
774609
774608
2026-10-10T12:40:09Z
Mewtow
31375
/* Les exceptions précises */
774609
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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.
Mais cela implique que 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. Il a un port de lecture séparé du port d'écriture. Le port de lecture est utilisé pour l'étage MEM, alors que le port d'écriture est utilisé dans l'étage de ''Writeback''.
Mais, il faut aussi propager les écritures d'un étage de pipeline au suivant, jusqu'à atteindre l'étage de ''Writeback''. Pour cela, il est possible d'enchainer plusieurs registres de pipeline. Mais nous avons vu dans le chapitre sur les mémoires FIFO qu'enchainer plusieurs registres à la suite est équivalent à utiliser une mémoire FIFO. Aussi, une solution équivalente une mémoire FIFO qui met en attente les écritures dans le cache. Elle est appelée la ''Write Queue'', '''file d'écriture''' en français.
: 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 est une simple mémoire FIFO, contenant quelques entrées. Une entrée contient une donnée et l'adresse d'écriture. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' transfère la donnée de la file d'écriture vers 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, faites à tord.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Store Queue sur un pipeline fixe]]
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>
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
rsmvu71tni4uuri1qd4yqfxtexqtx6b
774610
774609
2026-10-10T12:45:47Z
Mewtow
31375
/* Les écritures mémoire sont retardées avec des exceptions précises */
774610
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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>
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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0zy1bx48frzfh5yahj39jdfj9yzx0jz
774611
774610
2026-10-10T12:46:15Z
Mewtow
31375
/* Les écritures mémoire sont retardées avec des exceptions précises */
774611
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
===Les exceptions précises et imprécises===
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.
[[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, les exceptions sont alors dites 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.
===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 avec 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ktem8n6nqsgviwvhrq3d3rqk2ylo5gy
774612
774611
2026-10-10T12:47:19Z
Mewtow
31375
/* Les exceptions précises et imprécises */
774612
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
===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 avec 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
tm3d5xv89mewxc2srbejuxvgbzm8h6h
774614
774612
2026-10-10T12:47:38Z
Mewtow
31375
/* Le contournement avec la file d'écriture */
774614
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
===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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
===Les utilisations détournées du système d'exception précise===
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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, ce qui nous amène au sujet du chapitre suivant.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0a10vkiza76lrz9t1kdxs7e65kqx3wi
774615
774614
2026-10-10T12:48:19Z
Mewtow
31375
/* Les exceptions précises */
774615
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
6c5v0xd6ko4e4dztqyn7t6loenmr6sh
774616
774615
2026-10-10T12:49:04Z
Mewtow
31375
/* Le contournement : débloquer les dépendances */
774616
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multicycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles.
Le support des instructions multicycles entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
skk58qnu84lez2nstywhhst4ijurgfs
774625
774616
2026-10-10T13:00:13Z
Mewtow
31375
/* La gestion des instructions multicycles */
774625
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
8sfja0ha14hyr0g4qfq9ny0gzhxop5n
774627
774625
2026-10-10T13:09:08Z
Mewtow
31375
/* La gestion des dépendances de données */
774627
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. 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. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire. 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. Il s'agit bien d'une dépendance RAW assez particulière. Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide.
Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise une forme de contournement pour résoudre ce problème. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
e870zq7ozmsmoln42pk5ikbuzgr4d36
774628
774627
2026-10-10T13:10:51Z
Mewtow
31375
/* Le contournement de la file d'écriture */
774628
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Et elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline.
A chaque cycle, l'unité d'émission reçoit une micro-opération et l'envoie au chemin de données, dans les étages de pipeline suivant. On dit qu'elle '''émet une micro-opération'''. Par contre, dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Pour cela, les étages précédents sont gelés, que l'unité d'émission envoie des NOPs de pipeline pendant l'exécution de l'instruction multicycle. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. Il y a en effet une différence importante avec les accès mémoire : leur temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
d3n9fbawjbzhrsrpx8xks7us0kx0729
774629
774628
2026-10-10T13:30:42Z
Mewtow
31375
/* La gestion des instructions multicycles */
774629
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Mais il s'agit là des cas les moins fréquents. En général, une micro-opération ne doit pas être retardée, et l'unité d'émission la laisse passer à l'étage suivant. L'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
22goaegqt187vthaj7wqmo78uskskze
774630
774629
2026-10-10T13:31:13Z
Mewtow
31375
/* La gestion des instructions multicycles */
774630
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles. Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Typiquement, quand elle émet une instruction multicycle, elle retarde la micro-opération suivante. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
Mais il s'agit là des cas les moins fréquents. En général, une micro-opération ne doit pas être retardée, et l'unité d'émission la laisse passer à l'étage suivant. L'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
t2w77dvfuqpjvw53amjwi8fhs4824sc
774631
774630
2026-10-10T13:33:22Z
Mewtow
31375
/* La gestion des instructions multicycles */
774631
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Si une micro-opération ne doit pas être retardée, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
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, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0tkaqw90imy8qw7axs8k5xbagpnvn1c
774632
774631
2026-10-10T13:33:38Z
Mewtow
31375
/* La gestion des instructions multicycles */
774632
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Si une micro-opération ne doit pas être retardée, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
kr3f9dzvaf0ceqmqibd9qjcnzfp5zm2
774633
774632
2026-10-10T13:33:52Z
Mewtow
31375
/* La gestion des instructions multicycles */
774633
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de ache est en cours. C'est seulement quand le défaut est terminé et qu'il recoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline, le plus possible. D'ailleurs, nous passerons les prochains chapitres à parler de ces optimisations.
===La gestion des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Si une micro-opération ne doit pas être retardée, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2zkwfgequd9s4wba2hk2pajybzaa414
774634
774633
2026-10-10T13:34:22Z
Mewtow
31375
/* La gestion des défauts de cache */
774634
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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 des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
La mise en attente des instructions se fait soit dans l'unité de décodage, soit dans un circuit dédié. Dans ce qui suit, nous supposons qu'elle est gérée par une '''unité d'émission''', séparée du décodeur d'instruction. Elle décide des ''pipeline stall'' et gère les NOPs de pipeline. Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité d'émission/décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Si une micro-opération ne doit pas être retardée, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
nlu2iimjjtl0u87i8hzoa5ly7lusrtl
774635
774634
2026-10-10T13:38:05Z
Mewtow
31375
/* Le blocage du pipeline */
774635
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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 des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles n'apparaissent qu'avec des instructions multicycles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
a97ghmefo5y6izwn7wl241f0h6oupwl
774636
774635
2026-10-10T13:38:39Z
Mewtow
31375
/* La gestion des instructions multicycles */
774636
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING'', qui est à 1 pendant qu'un défaut de cache a lieu. Le signal est remis à zéro quand l'unité mémoire a terminé son travail et que la donnée lue est disponible. Ce signal est alors utilisé pour geler les registres du pipeline. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
Il y a plusieurs méthodes pour cela. 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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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 des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
60akao8q1brna1maxdeqk1ochkaj4vn
774660
774636
2026-10-10T16:46:27Z
Mewtow
31375
/* La gestion des défauts de cache */
774660
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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é. e processeur n'a pas le choix, et ce pour plusieurs raisons. La première est que si la lecture déclenche une exception
Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING''. 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. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[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]]
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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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 des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2zlkiitchhcxkz7v7otb0yjgjkavzou
774661
774660
2026-10-10T16:46:49Z
Mewtow
31375
/* La gestion des défauts de cache */
774661
wikitext
text/x-wiki
Les pipelines du chapitre précédent sont des '''pipelines de longueur fixe''', où toutes les instructions ont le même nombre d'étages. Et c'est un détail qui parait évident, on voit mal comment un pipeline pourrait avoir des étages différents par instructions. Néanmoins, sachez qu'il existe des '''pipelines dynamiques''', où certaines instructions ont des étages en plus, en moins. Le nombre d'étages varie suivant les instructions.
Les pipelines dynamiques utilisent au mieux les circuits présents dans le processeur. Nous avions vu dans le chapitre précédent qu'il arrive qu'un étage ne fasse pour certaines instructions bien précises. Par exemple, une instruction MOV n'utilise pas l'étage d'exécution correspondant aux ALUs, certaines instructions n'ont pas besoin de lire des registres et n'utilisent pas les étages ''read operands'', etc. Rien de tout cela avec les pipelines dynamiques.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Cependant, il n'est pas encore temps de parler des pipelines dynamiques, plus complexes. Nous allons nous concentrer sur les ''pipelines de longueur fixe''. Nous les appellerons '''pipelines fixes''', expression un peu plus courte. le fait que toutes les instructions ont le même nombre d'étage pose quelques problèmes. Notamment quand une instruction demande plusieurs cycles d'horloge pour s'exécuter.
==Le blocage du pipeline==
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
===La gestion des défauts de cache===
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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 des instructions multicycles===
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===La gestion des dépendances de données===
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
1ivkstn6cisfpan70nox0xzmtzym7lm
774664
774661
2026-10-10T17:03:07Z
Mewtow
31375
774664
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
Pour cela, l'unité mémoire émet un signal ''MEMORY ACCESS PENDING''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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 ''MEMORY ACCESS PENDING'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
h5y7upcmw0bng2x3lp8zsyetsxjs74d
774665
774664
2026-10-10T17:06:21Z
Mewtow
31375
/* Les défauts de cache */
774665
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
mcfvag6ebzaqz486cgo4u9mrxnicb3i
774666
774665
2026-10-10T17:06:37Z
Mewtow
31375
/* Le gel des registres */
774666
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
gtn0qpz7d9qwus93cri8d7g2nh6y0nm
774667
774666
2026-10-10T17:07:11Z
Mewtow
31375
/* Les instructions multicycles */
774667
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
===Les blocages de pipeline===
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
t5quhf8qwvkq9wynmqb76z0omg1tyzh
774668
774667
2026-10-10T17:08:42Z
Mewtow
31375
/* Les registres d'échelonnement */
774668
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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 à l'autre à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs 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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Les blocages de pipeline===
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
tiv8diwc8obc17hblgwszim3wdvstmd
774669
774668
2026-10-10T17:09:53Z
Mewtow
31375
/* Les registres d'échelonnement */
774669
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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]]
Il faut rappeler que pour les écritures en RAM, le processeur utilise une file d'écriture pour garantir que les écritures se font dans l'ordre. Nous en avions parlé dans le chapitre sur le pipeline, quand nous avions parlé des exceptions précises. On peut la voir comme une sorte d'équivalent des registres d'échelonnage, mais pour les écritures en mémoire. La file d'écriture est séparée des registres d'échelonnage, pour une raison simple : elle ne communique pas avec les registres, mais avec le cache. Sa sortie étant différente, elle est donc séparée.
===Les blocages de pipeline===
Une solution alternative n'utilise pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0r4nesml7cvo33oras35idlkldsb4v7
774670
774669
2026-10-10T17:43:46Z
Mewtow
31375
/* Les registres d'échelonnement */
774670
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de faire en sorte de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, une multiplication en prend 8 et un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. 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 multicycles]]
Reste à ajouter les étages qui ne font rien, mais qui calent les instructions sur la plus lente. 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'', ou encore des '''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 pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
6um0roa7wtnbboyfvz6ihsd81u6l14e
774671
774670
2026-10-10T17:47:42Z
Mewtow
31375
/* Les registres d'échelonnement */
774671
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multicycles]]
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 pas de registres d'échelonnage, elle n'utilise pas d'étages qui ne font rien. Avec elle, les instructions multicycles squattent donc l'étage d'exécution pendant plusieurs cycles. Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
6hchetddfagdsa93d5122vg59ajpfbi
774672
774671
2026-10-10T17:50:55Z
Mewtow
31375
/* Les blocages de pipeline */
774672
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multicycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend.
Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependnat, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Un autre problème a lieu avec deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer de nouvelle multiplications pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
jyywha6zrl3g49nxk8k2gsg0jrg31zx
774673
774672
2026-10-10T17:51:58Z
Mewtow
31375
/* Les blocages de pipeline */
774673
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multicycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend.
Et cela entraine l'apparition de nombreux problèmes, qui sont eux aussi résolus avec des blocages de pipeline. Intuitivement, vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Le pipeline devrait être bloqué pendant que les instructions multicycles utilisent l'étage d'exécution. Mais ce n'est pas exactement la réalité. En théorie, il est possible d'utiliser l'ALU entière pendant que la FPU ou le multiplieur exécutent une instruction multicycle. Cependant, faire ainsi a des conséquences assez subtiles.
Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, il se généralise assez bien.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
nzuru6j67tswbvgal1lf7virm6g054h
774674
774673
2026-10-10T17:56:06Z
Mewtow
31375
/* Les instructions multicycles */
774674
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les défauts de cache==
Les pipelines précédents doivent gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Prenons une instruction mémoire, qui lit une donnée. En temps normal, la lecture se fait dans le cache L1, l'accès mémoire se fait en un cycle. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : quelques 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.
En cas de défaut de cache, le processeur gèle complétement le pipeline. Les instructions cessent de progresser d'un étage à l'autre. Elles restent dans l'étage courante tant que l'accès mémoire n'est pas terminé. 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'''.
===Le gel des registres par l'unité mémoire===
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. L'idée est que les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils conservent leur valeur pendant toute la durée du blocage. Pour cela, ils réagissent à un signal qui leur dit dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Le signal vient de l'unité mémoire et il est nommé ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
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.
===Le blocage du pipeline n'est pas complet===
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les instructions multicycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multicycles 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. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multicycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Une telle situation est appelée un '''blocage de pipeline'''.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Vu que les instructions multicycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multicycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multicycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multicycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. C'est elle qui gèle le pipeline lors d'une instruction multicycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multicycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que 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 multicycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
c6oyo7iti0ygpfv0vbmwi3xdff4igrg
774675
774674
2026-10-10T18:06:29Z
Mewtow
31375
774675
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Une telle situation est appelée un '''blocage de pipeline'''.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0t3ju2waab5m6dshx1y6ijdsmps53fg
774676
774675
2026-10-10T18:07:00Z
Mewtow
31375
/* Les blocages de pipeline */
774676
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
Quand le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'accès mémoire n'est pas terminé. 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:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ci7ol3tdqn13httkxxc1f5x01mn4qgq
774677
774676
2026-10-10T18:07:15Z
Mewtow
31375
/* Les blocages de pipeline */
774677
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
Quand le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
6e64bnpu34ycdawm5rzgz31o0m5nf8n
774678
774677
2026-10-10T18:09:50Z
Mewtow
31375
/* Les blocages de pipeline */
774678
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
Quand le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Pour bloquer le pipeline, l'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
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.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
fof8azpkc9jrl5lbootzi3oyf7zzcsb
774679
774678
2026-10-10T18:10:18Z
Mewtow
31375
/* Les blocages de pipeline */
774679
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
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.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
lm2yi6279j84zyxm17y8c6kg0bm6ab3
774680
774679
2026-10-10T18:10:32Z
Mewtow
31375
/* Les blocages de pipeline */
774680
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Vu que les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles, les étages précédents doivent être gelés pendant ce temps. Au passage, vous avez peut-être pensé que l'ALU entière est inutilisée lors d'une instruction multi-cycle. Et vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
rymx5qrffnrdol3upolieszho9q8fbx
774681
774680
2026-10-10T18:11:28Z
Mewtow
31375
/* Les blocages de pipeline */
774681
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multi-cycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ge12fad1p2g1ackmqdoa4xp8xvrh9o4
774682
774681
2026-10-10T18:11:51Z
Mewtow
31375
/* Les registres d'échelonnement */
774682
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
jcsg7n6nao14ncgl1xa9qxgyx6dydya
774683
774682
2026-10-10T18:12:38Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
774683
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
Vous remarquerez que ce n'est pas la FPU ou le multiplieur qui déclenchent le gel du pipeline, contrairement à ce qu'on a avec les défauts de cache. La raison est le temps de calcul est connu à l'avance. On sait qu'une multiplication prend 5 cycles d'horloge, qu'une addition flottante en prend 8, une multiplication flottante en prend 12, etc. L'unité de décodage peut donc s'en charger. A l'opposé, les défauts de cache ont un temps d'exécution variable, sans compter qu'on ne sait pas à l'avance si un accès mémoire va faire un défaut ou un succès.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
g2b2y5b67lugz1xdqphu7lwx6wfrbtv
774686
774683
2026-10-10T18:16:03Z
Mewtow
31375
/* Les blocages de pipeline */
774686
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les dépendances structurelles===
Imaginons que le programme en cours d'execution veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les deux problèmes précédents sont des exemples de '''dépendance structurelle''', dont nous avons parlé plus haut. Pour rappel, cela veut dire que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Elles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
a15yu4xuhmj5gh477z8g0pojavfg1f7
774687
774686
2026-10-10T18:18:48Z
Mewtow
31375
/* Les dépendances structurelles */
774687
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les dépendances structurelles===
Ajouter des instructions multi-cycles à un pipeline entraine l'apparition de problèmes appelés des '''dépendances structurelles'''. Une dépendance structurelle signifie que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Les deux micro-opérations peuvent être dans deux étages différents. Le mieux est de prendre un exemple.
Imaginons qu'un programme veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Dans une telle situation, on doit bloquer le pipeline pour retarder l’exécution de la seconde multiplication. Et d'autres problèmes similaires sont possibles, bien qu'assez subtiles.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données==
Nous venons de voir que l'unité d'émission retarde les instructions quand elles ont une dépendance structurelle. Mais ce n'est pas le seul cas où l'unité d'émission doit intervenir. Les cas en question ont tous la même origine : 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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
L'instruction précédente doit avoir enregistré son résultat avant que la seconde le lise dans les registres, sans quoi le résultat de la seconde instruction est faux. Et avec un pipeline, cette contrainte n'est respectée que si l'unité d'émission intervient. L'unité d'émission retarde alors l'instruction suivante, tant que la première n'a pas écrit son résultat dans les registres.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
pgziswmlffhlqz7ovstkv9q89jz6bq6
774689
774687
2026-10-10T18:27:20Z
Mewtow
31375
774689
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génére un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
[[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]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous allons les voir en détail. Mais avant ça, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Qu'il s'agisse d'une dépendance de données, structurelle ou de certaines dépendances de contrôle, le processeur peut souvent résoudre la situation avec ce qui s'appelle des bulles de pipeline. Voyons cela en détail.
===Les dépendances structurelles===
Les '''dépendances structurelles''' ont déjà été mentionnées dans le chapitre précédent. Pour rappel, une dépendance structurelle signifie que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Les deux micro-opérations peuvent être dans deux étages différents. Généralement, le pipeline est conçu pour ne pas avoir de dépendances structurelles. Par exemple, le cache est séparé en un cache de données et d'instruction, pour éviter que les étages Fetch et MEM accèdent à un cache unique en même temps. Mais quand on ajoute des instructions multicycles, les choses deviennent plus compliquées. Et rien de mieux qu'un exemple pour le comprendre.
Imaginons qu'un programme veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Dans une telle situation, on doit bloquer le pipeline pour retarder l’exécution de la seconde multiplication. Il s'agit d'un exemple de dépendance structurelle, et il nous dit comment les gérer.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
7z3d1nzlvd09t1dx53dpuzhbb1uc9vz
774690
774689
2026-10-10T18:29:27Z
Mewtow
31375
/* Les blocages de pipeline */
774690
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
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.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous allons les voir en détail. Mais avant ça, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Qu'il s'agisse d'une dépendance de données, structurelle ou de certaines dépendances de contrôle, le processeur peut souvent résoudre la situation avec ce qui s'appelle des bulles de pipeline. Voyons cela en détail.
===Les dépendances structurelles===
Les '''dépendances structurelles''' ont déjà été mentionnées dans le chapitre précédent. Pour rappel, une dépendance structurelle signifie que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Les deux micro-opérations peuvent être dans deux étages différents. Généralement, le pipeline est conçu pour ne pas avoir de dépendances structurelles. Par exemple, le cache est séparé en un cache de données et d'instruction, pour éviter que les étages Fetch et MEM accèdent à un cache unique en même temps. Mais quand on ajoute des instructions multicycles, les choses deviennent plus compliquées. Et rien de mieux qu'un exemple pour le comprendre.
Imaginons qu'un programme veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Dans une telle situation, on doit bloquer le pipeline pour retarder l’exécution de la seconde multiplication. Il s'agit d'un exemple de dépendance structurelle, et il nous dit comment les gérer.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
7cj939u3bin34xbwcjg6g8u7eceoc7u
774691
774690
2026-10-10T18:29:49Z
Mewtow
31375
/* Les blocages de pipeline */
774691
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous allons les voir en détail. Mais avant ça, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Qu'il s'agisse d'une dépendance de données, structurelle ou de certaines dépendances de contrôle, le processeur peut souvent résoudre la situation avec ce qui s'appelle des bulles de pipeline. Voyons cela en détail.
===Les dépendances structurelles===
Les '''dépendances structurelles''' ont déjà été mentionnées dans le chapitre précédent. Pour rappel, une dépendance structurelle signifie que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Les deux micro-opérations peuvent être dans deux étages différents. Généralement, le pipeline est conçu pour ne pas avoir de dépendances structurelles. Par exemple, le cache est séparé en un cache de données et d'instruction, pour éviter que les étages Fetch et MEM accèdent à un cache unique en même temps. Mais quand on ajoute des instructions multicycles, les choses deviennent plus compliquées. Et rien de mieux qu'un exemple pour le comprendre.
Imaginons qu'un programme veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Dans une telle situation, on doit bloquer le pipeline pour retarder l’exécution de la seconde multiplication. Il s'agit d'un exemple de dépendance structurelle, et il nous dit comment les gérer.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ia7gmruorjc6yp7e861vsv9inmoi929
774692
774691
2026-10-10T18:30:19Z
Mewtow
31375
/* L'unité d'émission */
774692
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous allons les voir en détail. Mais avant ça, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Qu'il s'agisse d'une dépendance de données, structurelle ou de certaines dépendances de contrôle, le processeur peut souvent résoudre la situation avec ce qui s'appelle des bulles de pipeline. Voyons cela en détail.
===Les dépendances structurelles===
Les '''dépendances structurelles''' ont déjà été mentionnées dans le chapitre précédent. Pour rappel, une dépendance structurelle signifie que deux micro-opérations veulent utiliser un même circuit lors du même cycle d'horloge. Les deux micro-opérations peuvent être dans deux étages différents. Généralement, le pipeline est conçu pour ne pas avoir de dépendances structurelles. Par exemple, le cache est séparé en un cache de données et d'instruction, pour éviter que les étages Fetch et MEM accèdent à un cache unique en même temps. Mais quand on ajoute des instructions multicycles, les choses deviennent plus compliquées. Et rien de mieux qu'un exemple pour le comprendre.
Imaginons qu'un programme veuille exécuter deux multiplications consécutives. La première occupant le multiplieur pendant X cycles consécutifs, on ne peut pas lancer la suivante pendant ce temps. Avec deux multiplications consécutives, la seconde multiplication doit attendre son tour. Idem avec deux opérations flottantes. Dans une telle situation, on doit bloquer le pipeline pour retarder l’exécution de la seconde multiplication. Il s'agit d'un exemple de dépendance structurelle, et il nous dit comment les gérer.
Les dépendances structurelles sont gérées en retardant l'exécution des instructions qui suivent une instruction multi-cycle. Par exemple, si une multiplication prend 5 cycles pour s'exécuter dans le multiplieur, l'instruction suivante est retardée de 4 cycles, elle attend pendant 4 cycles. Durant ce temps d'attente, on insère des vides après l'unité d'émission : certains étages seront inoccupés et n'auront rien à faire. Les étages qui sont situés avant l'étage d'émission sont eux bloqués, la progression des instructions dedans est stoppée. Une telle situation est un cas particulier de ''pipeline stall'', qui est commandé par le décodeur.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances de données, ainsi que les dépendances structurelles, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
qtk44dwabv12we3ihc0a135qvaq2xhc
774693
774692
2026-10-10T18:31:29Z
Mewtow
31375
/* Les dépendances structurelles */
774693
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous allons les voir en détail. Mais avant ça, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Qu'il s'agisse d'une dépendance de données, structurelle ou de certaines dépendances de contrôle, le processeur peut souvent résoudre la situation avec ce qui s'appelle des bulles de pipeline. Voyons cela en détail.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances de données, ainsi que les dépendances structurelles, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
i6pkbnvqdvcd26rzlds73oqawhpt21d
774694
774693
2026-10-10T18:32:08Z
Mewtow
31375
/* Les dépendances d'instructions */
774694
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous les verrons en détail dans les chapitres qui suivent. Pour le moment, nous allons nous concentrer sur les dépendances de données.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances de données, ainsi que les dépendances structurelles, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. le contournement entre instructions simples est facile. Les instructions simples s'exécutent en un seul cycle ce qui fait que leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
fp6jjor1vzffw3qhnyfjy746dyzwq2v
774695
774694
2026-10-10T18:33:20Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
774695
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances d'instructions==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance'''.
Les instructions ont des dépendances très variées, mais on peut les résumer comme suit : deux instructions ont une dépendance quand elles manipulent la même ressource. En clair, quand elles manipulent en même temps le même registre, la même unité de calcul, la même adresse mémoire, etc. Il existe divers types de dépendances, appelées dépendances structurales, de contrôle et de données, que nous décrirons dans la suite du chapitre. Pour résumer d'une manière très succincte :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Nous les verrons en détail dans les chapitres qui suivent. Pour le moment, nous allons nous concentrer sur les dépendances de données.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Pour gérer les dépendances de données, ainsi que les dépendances structurelles, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : débloquer les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
06flyp179dxh37uftxefszgi1tsc2k1
774696
774695
2026-10-10T18:34:51Z
Mewtow
31375
774696
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données et le contournement==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
Pour gérer les dépendances de données, ainsi que les dépendances structurelles, il faut rajouter un étage du pipeline spécialement dédié. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations en cas de dépendance de données, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Les dépendances en question correspondent au cas où une instruction a pour opérande le résultat d'une instruction précédente. Elles imposent de bloquer le pipeline pendant quelques cycles. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
1wiyrzfknw8gj9cgfsbpjq5jrm2yanu
774697
774696
2026-10-10T18:35:59Z
Mewtow
31375
/* Les dépendances de données et le contournement */
774697
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données et le contournement==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ae50swg82tokptc7fjzarna8pc8p1z8
774698
774697
2026-10-10T18:37:58Z
Mewtow
31375
/* Les blocages de pipeline */
774698
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
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.
==Les dépendances de données et le contournement==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
565w315f71dse2r5tl2jf6td2yysuy3
774699
774698
2026-10-10T18:38:06Z
Mewtow
31375
/* Les défauts de cache */
774699
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances de données et le contournement==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2lmygvzlxwl9b2jn3depaqy8xm46dxg
774701
774699
2026-10-10T18:46:13Z
Mewtow
31375
/* Les dépendances de données et le contournement */
774701
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances de données==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2ocb0acy3stqsfw2wrrz9xjknpji6pa
774702
774701
2026-10-10T18:46:39Z
Mewtow
31375
/* Les dépendances de données */
774702
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'unité d'émission==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
4270w2zqiv4glbfm4dkax5d1q5it5bx
774703
774702
2026-10-10T18:46:56Z
Mewtow
31375
/* Les blocages de pipeline */
774703
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'unité d'émission==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
1om3e0i5vklsva5kocfgym82sjtjmi0
774704
774703
2026-10-10T18:47:15Z
Mewtow
31375
/* Les dépendances de données */
774704
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'unité d'émission==
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, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une '''dépendance de donnée'''.
===Les dépendances de données===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
43q6ygvyrw18w9jhkkdz1ev5dmrwr11
774705
774704
2026-10-10T18:47:36Z
Mewtow
31375
/* L'unité d'émission */
774705
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'unité d'émission==
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.
===Les dépendances d'instruction===
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une dépendance de donnée.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
pxzan9xp4hfkgtk08t4bnp9jexuvj28
774707
774705
2026-10-10T18:53:53Z
Mewtow
31375
/* L'unité d'émission */
774707
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une dépendance de donnée.
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
===L'unité d'émission===
Histoire de préparer la suite du chapitre, nous allons faire une précision importante : les processeurs modernes rajoutent un étage du pipeline spécialement dédié au blocage du pipeline. Il est appelé l''''étage d'émission''', et il contient une '''unité d'émission''', séparée du décodeur d'instruction. Elle met en attente les micro-opérations si besoin, mais aussi lorsqu'elle détecte une dépendance structurelle. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Dans certaines circonstances, elle retarde l'émission de la micro-opération. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''. Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
3rf75hqqetvbhj9hzs8eb37lhj5zsak
774732
774707
2026-10-10T20:17:09Z
Mewtow
31375
/* Les dépendances d'instruction */
774732
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une dépendance de donnée.
Il ne s'agit là que d'un exemple très précis de dépendances. Les instructions ont des dépendances très variées, qui sont classées comme suit :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Mais avant de les voir en détail, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances entre instructions dans un étage du pipeline spécialement dédié au blocage du pipeline : l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de données===
Le cas le plus simple de dépendance de données est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. La seconde doit alors s'exécuter après la première. Mais il y a d'autres types de dépendances de données bien 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, qui imposent que la première instruction s'exécute avant la seconde, au moins partiellement. Elle doit terminer 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.
Avec une dépendance RAW, la première instruction doit être totalement terminée avant que la seconde démarre. Émettre des bulles de pipeline est donc une solution toute indiquée. Les autres dépendances sont gérées différemment suivant le processeur, car elles n'apparaissent pas sur tous les pipelines. Par exemple, les pipelines que nous avons vus dans ce chapitre ne sont pas concernés par les dépendances WAR et WAW, seulement par les dépendances RAW. Dans le prochain chapitre, nous verrons des pipelines plus complexes, où les dépendances WAW peuvent apparaitre. Les dépendances WAR n'apparaissent que si le processeur utilise l'exécution dans le désordre, ce qui fait qu'on le verra dans le chapitre sur cette exécution dans le désordre.
===Les dépendances de registre et d'adresse===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==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, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
8pbp33beh1b7ywh5g7vshlow6xw5hdl
774733
774732
2026-10-10T20:17:55Z
Mewtow
31375
/* Le contournement : réduire les dépendances */
774733
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, des dépendances peuvent survenir. Un exemple de dépendance serait le cas où une instruction a comme opérande le résultat d'une instruction précédente. Le résultat d'une opération doit être enregistré dans les registres, avant de pouvoir ê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é. Les deux instructions ont alors une dépendance de donnée.
Il ne s'agit là que d'un exemple très précis de dépendances. Les instructions ont des dépendances très variées, qui sont classées comme suit :
* Les '''dépendances de données''' surviennent quand deux instructions manipulent le même registre ou la même adresse mémoire.
* Les '''dépendances structurelles''' surviennent quand deux instructions veulent utiliser le même circuit en même temps.
* les '''dépendances de contrôle''' sont liées aux branchements et aux interruptions.
Mais avant de les voir en détail, nous allons devoir parler rapidement de comment le processeur réagit face à une dépendance. Les dépendances imposent qu'une première instruction soit totalement terminée avant qu'on démarre la seconde, ou du moins qu'elle ait progressé suffisamment dans le pipeline. Pour cela, il suffit de retarder l'exécution de la seconde instruction tant que les conditions nécessaires pour son exécution ne sont pas réunies. Par exemple, prenons le cas où une instruction a pour opérande le résultat d'une autre : la seconde doit attendre que le résultat de la première soit calculé avant de démarrer, il se peut qu'elle soit retardée durant quelques cycles pour cela.
Les processeurs détectent les dépendances entre instructions dans un étage du pipeline spécialement dédié au blocage du pipeline : l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide des ''pipeline stall'' et gère les NOPs de pipeline. 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.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de données===
Le cas le plus simple de dépendance de données est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. La seconde doit alors s'exécuter après la première. Mais il y a d'autres types de dépendances de données bien 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, qui imposent que la première instruction s'exécute avant la seconde, au moins partiellement. Elle doit terminer 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.
Avec une dépendance RAW, la première instruction doit être totalement terminée avant que la seconde démarre. Émettre des bulles de pipeline est donc une solution toute indiquée. Les autres dépendances sont gérées différemment suivant le processeur, car elles n'apparaissent pas sur tous les pipelines. Par exemple, les pipelines que nous avons vus dans ce chapitre ne sont pas concernés par les dépendances WAR et WAW, seulement par les dépendances RAW. Dans le prochain chapitre, nous verrons des pipelines plus complexes, où les dépendances WAW peuvent apparaitre. Les dépendances WAR n'apparaissent que si le processeur utilise l'exécution dans le désordre, ce qui fait qu'on le verra dans le chapitre sur cette exécution dans le désordre.
===Les dépendances de registre et d'adresse===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0jxflxe2qh14plkeepa4mglkfffe24g
774754
774733
2026-10-11T00:33:43Z
Mewtow
31375
/* Les dépendances d'instruction */
774754
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Nous avions parlé rapidement des '''dépendances structurelles''', dans le chapitre précédent, qui surviennent quand deux instructions veulent utiliser un même circuit en même temps. Mais il y a aussi des '''dépendances de contrôle''' et des '''dépendances de données'''. Les dépendances de données seront vues en détail dans le chapitre sur l’exécution dans le désordre, les dépendances de contrôle seront le sujet du prochain chapitre. Pour le moment, nous n'avons besoin que de nous préoccuper d'un cas bien précis.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline. Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de données===
Le cas le plus simple de dépendance de données est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. La seconde doit alors s'exécuter après la première. Mais il y a d'autres types de dépendances de données bien 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, qui imposent que la première instruction s'exécute avant la seconde, au moins partiellement. Elle doit terminer 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.
Avec une dépendance RAW, la première instruction doit être totalement terminée avant que la seconde démarre. Émettre des bulles de pipeline est donc une solution toute indiquée. Les autres dépendances sont gérées différemment suivant le processeur, car elles n'apparaissent pas sur tous les pipelines. Par exemple, les pipelines que nous avons vus dans ce chapitre ne sont pas concernés par les dépendances WAR et WAW, seulement par les dépendances RAW. Dans le prochain chapitre, nous verrons des pipelines plus complexes, où les dépendances WAW peuvent apparaitre. Les dépendances WAR n'apparaissent que si le processeur utilise l'exécution dans le désordre, ce qui fait qu'on le verra dans le chapitre sur cette exécution dans le désordre.
===Les dépendances de registre et d'adresse===
Les '''dépendances de données''' sont nombreuses, mais la plupart n'apparaissent que si le pipeline a des caractéristiques bien précises. Pour les pipelines que nous avons étudié, il n'y en a qu'un seul type : les dépendances où une instruction lit le résultat d'une instruction précédente. Nous verrons les autres types de dépendances en temps voulu. Les dépendances peuvent se classer en deux types, qu'on va détailler dans ce qui suit.
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.
: Précision : les dépendances en question sont entre deux instructions en vol, c'est à dire qu'elles sont dans le pipeline en même temps.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
a8ip870pae3z96mh048lpzynwy1zr8p
774755
774754
2026-10-11T00:35:40Z
Mewtow
31375
/* Les dépendances d'instruction */
774755
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Nous avions parlé rapidement des '''dépendances structurelles''', dans le chapitre précédent, qui surviennent quand deux instructions veulent utiliser un même circuit en même temps. Mais il y a aussi des '''dépendances de contrôle''' et des '''dépendances de données'''. Les dépendances de données seront vues en détail dans le chapitre sur l’exécution dans le désordre, les dépendances de contrôle seront le sujet du prochain chapitre. Pour le moment, nous n'avons besoin que de nous préoccuper d'un cas bien précis.
===Les dépendances ''Read After Write''===
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
sixiudm9tpuxz5thee5ju5uu87av0yc
774756
774755
2026-10-11T00:44:40Z
Mewtow
31375
/* L'unité d’émission */
774756
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Nous avions parlé rapidement des '''dépendances structurelles''', dans le chapitre précédent, qui surviennent quand deux instructions veulent utiliser un même circuit en même temps. Mais il y a aussi des '''dépendances de contrôle''' et des '''dépendances de données'''. Les dépendances de données seront vues en détail dans le chapitre sur l’exécution dans le désordre, les dépendances de contrôle seront le sujet du prochain chapitre. Pour le moment, nous n'avons besoin que de nous préoccuper d'un cas bien précis.
===Les dépendances ''Read After Write''===
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
cdgl2qeu434arlxg5wgy1pc0ip44eui
774758
774756
2026-10-11T00:45:55Z
Mewtow
31375
/* Les dépendances Read After Write */
774758
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée.
Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Nous avions parlé rapidement des '''dépendances structurelles''', dans le chapitre précédent, qui surviennent quand deux instructions veulent utiliser un même circuit en même temps. Mais il y a aussi des '''dépendances de contrôle''' et des '''dépendances de données'''. Les dépendances de données seront vues en détail dans le chapitre sur l’exécution dans le désordre, les dépendances de contrôle seront le sujet du prochain chapitre. Pour le moment, nous n'avons besoin que de nous préoccuper d'un cas bien précis.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
66wbuwqfhotfo4zqeeissyzgvqb0xjg
774759
774758
2026-10-11T00:46:41Z
Mewtow
31375
/* Les dépendances d'instruction */
774759
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Fort heureusement, nous n'avons besoin que de nous préoccuper d'un cas bien précis, pour le moment.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
rsnpzpg6dcneuieh62e7sspa7g2so89
774760
774759
2026-10-11T00:47:05Z
Mewtow
31375
/* La détection des dépendances de données */
774760
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Fort heureusement, nous n'avons besoin que de nous préoccuper d'un cas bien précis, pour le moment.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
===Le registre de disponibilité===
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. L'idée est qu'un défaut de cache lève une exception matérielle. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
aufel4wcnt92yg4bshw69t59z22jhay
774801
774760
2026-10-11T02:14:38Z
Mewtow
31375
/* Le hardware pour les exceptions précises */
774801
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Fort heureusement, nous n'avons besoin que de nous préoccuper d'un cas bien précis, pour le moment.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
===Le registre de disponibilité===
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ch741asjvl7zqn8lfehoju111vt7cyb
Mathc initiation/007q
0
84573
774617
2026-10-10T12:51:37Z
Xhungab
23827
news
774617
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 = +PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00
2 A[02] = +1.000e+00 Phase[02] = +2.102e-16
3 A[03] = +4.444e-01 Phase[03] = -3.142e+00
4 A[04] = +2.500e-01 Phase[04] = +1.543e-16
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00
6 A[06] = +1.111e-01 Phase[06] = -2.684e-15
7 A[07] = +8.163e-02 Phase[07] = -3.142e+00
8 A[08] = +6.250e-02 Phase[08] = -1.326e-14
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
278tefp6v6mggjqtlue7u07vh7ffnuj
774626
774617
2026-10-10T13:02:42Z
Xhungab
23827
774626
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 = +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);
if (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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00
3 A[03] = +4.444e-01 Phase[03] = +3.142e+00
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00
7 A[07] = +8.163e-02 Phase[07] = +3.142e+00
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
pbcz9zg8k9ynvh2tl3c553em9zg8njh
774734
774626
2026-10-10T20:34:14Z
Xhungab
23827
774734
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 = +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);
if (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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00
3 A[03] = +4.444e-01 Phase[03] = +3.142e+00
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00
7 A[07] = +8.163e-02 Phase[07] = +3.142e+00
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
ka6966vx4pq2y8x1210bhzoe0rk4i58
774735
774734
2026-10-10T20:34:50Z
Xhungab
23827
774735
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 = +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);
if (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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00
3 A[03] = +4.444e-01 Phase[03] = +3.142e+00
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00
7 A[07] = +8.163e-02 Phase[07] = +3.142e+00
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
gcl9l1kjqm63ns3wsz9gm5a7xwc5r7x
774753
774735
2026-10-10T22:27:23Z
Xhungab
23827
774753
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 = +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);
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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00
3 A[03] = +4.444e-01 Phase[03] = +3.142e+00
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00
7 A[07] = +8.163e-02 Phase[07] = +3.142e+00
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
c4kjywo5x9xt35xzphihb8nz5ogpc6y
774809
774753
2026-10-11T09:21:46Z
Xhungab
23827
774809
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 = +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);
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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+7.3e rad "
"= %+7.2f°\n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> (t^2))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.000e+00 Phase[01] = +3.142e+00 rad = +180.00°
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00 rad = +0.00°
3 A[03] = +4.444e-01 Phase[03] = +3.142e+00 rad = +180.00°
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00 rad = +0.00°
5 A[05] = +1.600e-01 Phase[05] = +3.142e+00 rad = +180.00°
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00 rad = +0.00°
7 A[07] = +8.163e-02 Phase[07] = +3.142e+00 rad = +180.00°
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00 rad = +0.00°
9 A[09] = +4.938e-02 Phase[09] = +3.142e+00 rad = +180.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
9ixf5707apkgnz2z4acpqr2othu44af
Mathc initiation/a9c
0
84574
774737
2026-10-10T20:40:26Z
Xhungab
23827
news
774737
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*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
if (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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n Amplitude spectrumn Phase spectrum
1 A[01] = +4.472e+00 Phase[01] = +2.678e+00
2 A[02] = +1.000e+00 Phase[02] = +0.000e+00
3 A[03] = +8.012e-01 Phase[03] = +2.159e+00
4 A[04] = +2.500e-01 Phase[04] = +0.000e+00
5 A[05] = +4.308e-01 Phase[05] = +1.951e+00
6 A[06] = +1.111e-01 Phase[06] = +0.000e+00
7 A[07] = +2.972e-01 Phase[07] = +1.849e+00
8 A[08] = +6.250e-02 Phase[08] = +0.000e+00
9 A[09] = +2.276e-01 Phase[09] = +1.789e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
s92i0xnnu0ogu8orq1tx1wdl6kq682o
774739
774737
2026-10-10T20:53:43Z
Xhungab
23827
774739
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 = +1;
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 (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\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.333333333
n Amplitude spectrumn Phase spectrum
1 A[01] = +7.547e-01 Phase[01] = +2.138e+00
2 A[02] = +1.013e-01 Phase[02] = +0.000e+00
3 A[03] = +2.169e-01 Phase[03] = +1.780e+00
4 A[04] = +2.533e-02 Phase[04] = +0.000e+00
5 A[05] = +1.284e-01 Phase[05] = +1.697e+00
6 A[06] = +1.126e-02 Phase[06] = +0.000e+00
7 A[07] = +9.132e-02 Phase[07] = +1.661e+00
8 A[08] = +6.332e-03 Phase[08] = +0.000e+00
9 A[09] = +7.091e-02 Phase[09] = +1.641e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
dfln06vmf2r0md1r1ywh73f8avzqw14
774752
774739
2026-10-10T22:11:11Z
Xhungab
23827
774752
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 = +1;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.333333333
n Amplitude spectrumn Phase spectrum
1 A[01] = +7.547e-01 Phase[01] = +2.138e+00
2 A[02] = +3.340e-01 Phase[02] = -1.263e+00
3 A[03] = +2.169e-01 Phase[03] = +1.780e+00
4 A[04] = +1.612e-01 Phase[04] = -1.413e+00
5 A[05] = +1.284e-01 Phase[05] = +1.697e+00
6 A[06] = +1.067e-01 Phase[06] = -1.465e+00
7 A[07] = +9.132e-02 Phase[07] = +1.661e+00
8 A[08] = +7.983e-02 Phase[08] = -1.491e+00
9 A[09] = +7.091e-02 Phase[09] = +1.641e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
faf2qjsidnqkksso0jmf3wc4dertufd
774810
774752
2026-10-11T09:24:40Z
Xhungab
23827
774810
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 = +1;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+7.3e rad "
"= %+7.2f°\n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="c">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> (x(x+1))
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.333333333
n Amplitude spectrumn Phase spectrum
1 A[01] = +7.547e-01 Phase[01] = +2.138e+00 rad = +122.48°
2 A[02] = +3.340e-01 Phase[02] = -1.263e+00 rad = -72.34°
3 A[03] = +2.169e-01 Phase[03] = +1.780e+00 rad = +101.98°
4 A[04] = +1.612e-01 Phase[04] = -1.413e+00 rad = -80.96°
5 A[05] = +1.284e-01 Phase[05] = +1.697e+00 rad = +97.26°
6 A[06] = +1.067e-01 Phase[06] = -1.465e+00 rad = -83.94°
7 A[07] = +9.132e-02 Phase[07] = +1.661e+00 rad = +95.20°
8 A[08] = +7.983e-02 Phase[08] = -1.491e+00 rad = -85.45°
9 A[09] = +7.091e-02 Phase[09] = +1.641e+00 rad = +94.05°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
f6i4v9mxuz8e92n8dmq2p0iq9o5fxmx
Mathc initiation/a9d
0
84575
774740
2026-10-10T21:00:30Z
Xhungab
23827
news
774740
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|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 = PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +1.570796327
n Amplitude spectrumn Phase spectrum
1 A[01] = +1.000e+00 Phase[01] = -1.571e+00
2 A[02] = +5.000e-01 Phase[02] = -1.571e+00
3 A[03] = +3.333e-01 Phase[03] = -1.571e+00
4 A[04] = +2.500e-01 Phase[04] = -1.571e+00
5 A[05] = +2.000e-01 Phase[05] = -1.571e+00
6 A[06] = +1.667e-01 Phase[06] = -1.571e+00
7 A[07] = +1.429e-01 Phase[07] = -1.571e+00
8 A[08] = +1.250e-01 Phase[08] = -1.571e+00
9 A[09] = +1.111e-01 Phase[09] = -1.571e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
24srrb015rw7r4xiyr90rwe157x9axt
774742
774740
2026-10-10T21:02:58Z
Xhungab
23827
774742
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 = PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+.3e \n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[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
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +1.570796327
n Amplitude spectrumn Phase spectrum
1 A[01] = +1.000e+00 Phase[01] = -1.571e+00
2 A[02] = +5.000e-01 Phase[02] = -1.571e+00
3 A[03] = +3.333e-01 Phase[03] = -1.571e+00
4 A[04] = +2.500e-01 Phase[04] = -1.571e+00
5 A[05] = +2.000e-01 Phase[05] = -1.571e+00
6 A[06] = +1.667e-01 Phase[06] = -1.571e+00
7 A[07] = +1.429e-01 Phase[07] = -1.571e+00
8 A[08] = +1.250e-01 Phase[08] = -1.571e+00
9 A[09] = +1.111e-01 Phase[09] = -1.571e+00
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
l5ikonbamhffjoazv8dpnxsnbuqjgna
774811
774742
2026-10-11T09:27:55Z
Xhungab
23827
774811
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 = PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+7.3e rad "
"= %+7.2f°\n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> t
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +1.570796327
n Amplitude spectrumn Phase spectrum
1 A[01] = +1.000e+00 Phase[01] = -1.571e+00 rad = -90.00°
2 A[02] = +5.000e-01 Phase[02] = -1.571e+00 rad = -90.00°
3 A[03] = +3.333e-01 Phase[03] = -1.571e+00 rad = -90.00°
4 A[04] = +2.500e-01 Phase[04] = -1.571e+00 rad = -90.00°
5 A[05] = +2.000e-01 Phase[05] = -1.571e+00 rad = -90.00°
6 A[06] = +1.667e-01 Phase[06] = -1.571e+00 rad = -90.00°
7 A[07] = +1.429e-01 Phase[07] = -1.571e+00 rad = -90.00°
8 A[08] = +1.250e-01 Phase[08] = -1.571e+00 rad = -90.00°
9 A[09] = +1.111e-01 Phase[09] = -1.571e+00 rad = -90.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
qg9hthqerrnp08iul0uh4gao2usxdqu
Fonctionnement d'un ordinateur/Les schedulers et stations de réservation
0
84576
774785
2026-10-11T01:44:54Z
Mewtow
31375
Page créée avec « ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décod... »
774785
wikitext
text/x-wiki
==L'implémentation d'un ''scheduler'' : généralités==
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
rk96nb0b1zw3548e9iioxv12j2ssnjh
774788
774785
2026-10-11T01:45:31Z
Mewtow
31375
774788
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum.
Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==L'implémentation d'un ''scheduler'' : généralités==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
7smapkh7lw5pqigxi83myf7awnppu44
774789
774788
2026-10-11T01:46:23Z
Mewtow
31375
774789
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==L'implémentation d'un ''scheduler'' : généralités==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
gl86wya5dfigyilaalsw18vfy2c0d8s
774790
774789
2026-10-11T01:46:59Z
Mewtow
31375
/* Les scheduler avec allocation temporelle statique */
774790
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==L'implémentation d'un ''scheduler'' : généralités==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'exécution dans le désordre
| prevText=L'exécution dans le désordre
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
c1j6w0cadf0yy970y0wcmuy551lz8z4
774795
774790
2026-10-11T01:50:03Z
Mewtow
31375
/* La logique d'éveil et de sélection */
774795
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==L'implémentation d'un ''scheduler'' : généralités==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
===Les stations de réservation===
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
La sélection de la micro-opération à émettre est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'exécution dans le désordre
| prevText=L'exécution dans le désordre
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
30phwf431e6cjdppvumurnk9u1bn8dh